mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-07 18:14:10 +00:00
Merge branch 'rbac' into read_roles
This commit is contained in:
@@ -66,7 +66,12 @@ metrics: source/reference/metrics.rst
|
||||
source/reference/metrics.rst: generate-metrics.py
|
||||
python3 generate-metrics.py
|
||||
|
||||
html: rest-api metrics
|
||||
scopes: source/rbac/scope-table.md
|
||||
|
||||
source/rbac/scope-table.md: source/rbac/generate-scope-table.py
|
||||
python3 source/rbac/generate-scope-table.py
|
||||
|
||||
html: rest-api metrics scopes
|
||||
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
|
||||
@echo
|
||||
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
|
||||
|
@@ -212,7 +212,7 @@ if on_rtd:
|
||||
# build both metrics and rest-api, since RTD doesn't run make
|
||||
from subprocess import check_call as sh
|
||||
|
||||
sh(['make', 'metrics', 'rest-api'], cwd=docs)
|
||||
sh(['make', 'metrics', 'rest-api', 'scopes'], cwd=docs)
|
||||
|
||||
# -- Spell checking -------------------------------------------------------
|
||||
|
||||
|
99
docs/source/rbac/generate-scope-table.py
Normal file
99
docs/source/rbac/generate-scope-table.py
Normal file
@@ -0,0 +1,99 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
|
||||
from pytablewriter import MarkdownTableWriter
|
||||
|
||||
from jupyterhub.scopes import scope_definitions
|
||||
|
||||
HERE = os.path.abspath(os.path.dirname(__file__))
|
||||
|
||||
|
||||
class ScopeTableGenerator:
|
||||
def __init__(self):
|
||||
self.scopes = scope_definitions
|
||||
|
||||
@classmethod
|
||||
def create_writer(cls, table_name, headers, values):
|
||||
writer = MarkdownTableWriter()
|
||||
writer.table_name = table_name
|
||||
writer.headers = headers
|
||||
writer.value_matrix = values
|
||||
writer.margin = 1
|
||||
return writer
|
||||
|
||||
def _get_scope_relationships(self):
|
||||
"""Returns a tuple of dictionary of all scope-subscope pairs and a list of just subscopes:
|
||||
|
||||
({scope: subscope}, [subscopes])
|
||||
|
||||
used for creating hierarchical scope table in _parse_scopes()
|
||||
"""
|
||||
pairs = []
|
||||
for scope in self.scopes.keys():
|
||||
if self.scopes[scope].get('subscopes'):
|
||||
for subscope in self.scopes[scope]['subscopes']:
|
||||
pairs.append((scope, subscope))
|
||||
else:
|
||||
pairs.append((scope, None))
|
||||
subscopes = [pair[1] for pair in pairs]
|
||||
pairs_dict = defaultdict(list)
|
||||
for scope, subscope in pairs:
|
||||
pairs_dict[scope].append(subscope)
|
||||
return pairs_dict, subscopes
|
||||
|
||||
def _get_top_scopes(self, subscopes):
|
||||
"""Returns a list of highest level scopes
|
||||
(not a subscope of any other scopes)"""
|
||||
top_scopes = []
|
||||
for scope in self.scopes.keys():
|
||||
if scope not in subscopes:
|
||||
top_scopes.append(scope)
|
||||
return top_scopes
|
||||
|
||||
def _parse_scopes(self):
|
||||
"""Returns a list of table rows where row:
|
||||
[indented scopename string, scope description string]"""
|
||||
scope_pairs, subscopes = self._get_scope_relationships()
|
||||
top_scopes = self._get_top_scopes(subscopes)
|
||||
|
||||
table_rows = []
|
||||
md_indent = " "
|
||||
|
||||
def _add_subscopes(table_rows, scopename, depth=0):
|
||||
description = self.scopes[scopename]['description']
|
||||
table_row = [f"{md_indent*depth}`{scopename}`", description]
|
||||
table_rows.append(table_row)
|
||||
for subscope in scope_pairs[scopename]:
|
||||
if subscope:
|
||||
_add_subscopes(table_rows, subscope, depth + 1)
|
||||
|
||||
for scope in top_scopes:
|
||||
_add_subscopes(table_rows, scope)
|
||||
|
||||
return table_rows
|
||||
|
||||
def write_table(self):
|
||||
"""Generates the scope table in markdown format and writes it into scope-table.md file"""
|
||||
filename = f"{HERE}/scope-table.md"
|
||||
table_name = ""
|
||||
headers = ["Scope", "Description"]
|
||||
values = self._parse_scopes()
|
||||
writer = self.create_writer(table_name, headers, values)
|
||||
|
||||
title = "Table 1. Available scopes and their hierarchy"
|
||||
content = f"{title}\n{writer.dumps()}"
|
||||
with open(filename, 'w') as f:
|
||||
f.write(content)
|
||||
print(f"Generated {filename}.")
|
||||
print(
|
||||
"Run 'make clean' before 'make html' to ensure the built scopes.html contains latest scope table changes."
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
table_generator = ScopeTableGenerator()
|
||||
table_generator.write_table()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@@ -1,4 +1,4 @@
|
||||
# Scopes
|
||||
# Scopes in JupyterHub
|
||||
|
||||
A scope has a syntax-based design that reveals which resources it provides access to. Resources are objects with a type, associated data, relationships to other resources, and a set of methods that operate on them (see [RESTful API](https://restful-api-design.readthedocs.io/en/latest/resources.html) documentation for more information).
|
||||
|
||||
@@ -81,37 +81,10 @@ The payload of an API call can be filtered both horizontally and vertically simu
|
||||
## Available scopes
|
||||
|
||||
Table below lists all available scopes and illustrates their hierarchy. Indented scopes indicate subscopes of the scope(s) above them.
|
||||
% TODO: Automatically generate this table from code when creating docs
|
||||
Table 1. Available scopes and their hierarchy
|
||||
| Scope name | Description |
|
||||
| :--------- | :---------- |
|
||||
| (no scope) | Allows for only identifying the owning entity. |
|
||||
| `self` | Metascope, grants access to user's own resources; resolves to (no scope) for services. |
|
||||
| `all` | Metascope, valid for tokens only. Grants access to everything that the token's owning entity can do. |
|
||||
| `admin:users` | Grants read, write, create and delete access to users and their authentication state _but not their servers or tokens._ |
|
||||
| `admin:users:auth_state` | Grants access to users' authentication state only. |
|
||||
| `users` | Grants read and write permissions to users' models _apart from servers, tokens and authentication state_. |
|
||||
| `users:activity` | Grants access to read and post users' activity only. |
|
||||
| `read:users` | Read-only access to users' models _apart from servers, tokens and authentication state_. |
|
||||
| `read:users:name` | Read-only access to users' names. |
|
||||
| `read:users:roles` | Read-only access to a list of users' roles names. |
|
||||
| `read:users:groups` | Read-only access to a list of users' group names. |
|
||||
| `read:users:activity` | Read-only access to users' activity. |
|
||||
| `admin:users:servers` | Grants read, start/stop, create and delete permissions to users' servers and their state. |
|
||||
| `admin:users:server_state` | Grants access to servers' state only. |
|
||||
| `users:servers` | Allows for starting/stopping users' servers in addition to read access to their models. _Does not include the server state_. |
|
||||
| `read:users:servers` | Read-only access to users' server models. _Does not include the server state_. |
|
||||
| `users:tokens` | Grants read, write, create and delete permissions to users' tokens. |
|
||||
| `read:users:tokens` | Read-only access to users' tokens. |
|
||||
| `admin:groups` | Grants read, write, create and delete access to groups. |
|
||||
| `groups` | Grants read and write permissions to groups, including adding/removing users to/from groups. |
|
||||
| `read:groups` | Read-only access to groups. |
|
||||
| `read:services` | Read-only access to service models. |
|
||||
| `read:services:name` | Read-only access to service names. |
|
||||
| `read:services:roles` | Read-only access to a list of service roles names. |
|
||||
| `read:hub` | Read-only access to detailed information about the Hub. |
|
||||
| `proxy` | Allows for obtaining information about the proxy's routing table, for syncing the Hub with proxy and notifying the Hub about a new proxy. |
|
||||
| `shutdown` | Grants access to shutdown the hub. |
|
||||
|
||||
```{include} scope-table.md
|
||||
|
||||
```
|
||||
|
||||
```{Caution}
|
||||
Note that only the {ref}`horizontal filtering <horizontal-filtering-target>` can be added to scopes to customize them. \
|
||||
|
@@ -26,3 +26,4 @@ what happens under-the-hood when you deploy and configure your JupyterHub.
|
||||
config-proxy
|
||||
config-sudo
|
||||
config-reference
|
||||
oauth
|
||||
|
373
docs/source/reference/oauth.md
Normal file
373
docs/source/reference/oauth.md
Normal file
@@ -0,0 +1,373 @@
|
||||
# JupyterHub and OAuth
|
||||
|
||||
JupyterHub uses OAuth 2 internally as a mechanism for authenticating users.
|
||||
As such, JupyterHub itself always functions as an OAuth **provider**.
|
||||
More on what that means [below](oauth-terms).
|
||||
|
||||
Additionally, JupyterHub is _often_ deployed with [oauthenticator](https://oauthenticator.readthedocs.io),
|
||||
where an external identity provider, such as GitHub or KeyCloak, is used to authenticate users.
|
||||
When this is the case, there are _two_ nested oauth flows:
|
||||
an _internal_ oauth flow where JupyterHub is the **provider**,
|
||||
and and _external_ oauth flow, where JupyterHub is a **client**.
|
||||
|
||||
This means that when you are using JupyterHub, there is always _at least one_ and often two layers of OAuth involved in a user logging in and accessing their server.
|
||||
|
||||
Some relevant points:
|
||||
|
||||
- Single-user servers _never_ need to communicate with or be aware of the upstream provider configured in your Authenticator.
|
||||
As far as they are concerned, only JupyterHub is an OAuth provider,
|
||||
and how users authenticate with the Hub itself is irrelevant.
|
||||
- When talking to a single-user server,
|
||||
there are ~always two tokens:
|
||||
a token issued to the server itself to communicate with the Hub API,
|
||||
and a second per-user token in the browser to represent the completed login process and authorized permissions.
|
||||
More on this [later](two-tokens).
|
||||
|
||||
(oauth-terms)=
|
||||
|
||||
## Key OAuth terms
|
||||
|
||||
Here are some key definitions to keep in mind when we are talking about OAuth.
|
||||
You can also read more detail [here](https://www.oauth.com/oauth2-servers/definitions/).
|
||||
|
||||
- **provider** the entity responsible for managing identity and authorization,
|
||||
always a web server.
|
||||
JupyterHub is _always_ an oauth provider for JupyterHub's components.
|
||||
When OAuthenticator is used, an external service, such as GitHub or KeyCloak, is also an oauth provider.
|
||||
- **client** An entity that requests OAuth **tokens** on a user's behalf,
|
||||
generally a web server of some kind.
|
||||
OAuth **clients** are services that _delegate_ authentication and/or authorization
|
||||
to an OAuth **provider**.
|
||||
JupyterHub _services_ or single-user _servers_ are OAuth **clients** of the JupyterHub **provider**.
|
||||
When OAuthenticator is used, JupyterHub is itself _also_ an OAuth **client** for the external oauth **provider**, e.g. GitHub.
|
||||
- **browser** A user's web browser, which makes requests and stores things like cookies
|
||||
- **token** The secret value used to represent a user's authorization. This is the final product of the OAuth process.
|
||||
- **code** A short-lived temporary secret that the **client** exchanges
|
||||
for a **token** at the conclusion of oauth,
|
||||
in what's generally called the "oauth callback handler."
|
||||
|
||||
## One oauth flow
|
||||
|
||||
OAuth **flow** is what we call the sequence of HTTP requests involved in authenticating a user and issuing a token, ultimately used for authorized access to a service or single-user server.
|
||||
|
||||
A single oauth flow generally goes like this:
|
||||
|
||||
### OAuth request and redirect
|
||||
|
||||
1. A **browser** makes an HTTP request to an oauth **client**.
|
||||
2. There are no credentials, so the client _redirects_ the browser to an "authorize" page on the oauth **provider** with some extra information:
|
||||
- the oauth **client id** of the client itself
|
||||
- the **redirect uri** to be redirected back to after completion
|
||||
- the **scopes** requested, which the user should be presented with to confirm.
|
||||
This is the "X would like to be able to Y on your behalf. Allow this?" page you see on all the "Login with ..." pages around the Internet.
|
||||
3. During this authorize step,
|
||||
the browser must be _authenticated_ with the provider.
|
||||
This is often already stored in a cookie,
|
||||
but if not the provider webapp must begin its _own_ authentication process before serving the authorization page.
|
||||
This _may_ even begin another oauth flow!
|
||||
4. After the user tells the provider that they want to proceed with the authorization,
|
||||
the provider records this authorization in a short-lived record called an **oauth code**.
|
||||
5. Finally, the oauth provider redirects the browser _back_ to the oauth client's "redirect uri"
|
||||
(or "oauth callback uri"),
|
||||
with the oauth code in a url parameter.
|
||||
|
||||
That's the end of the requests made between the **browser** and the **provider**.
|
||||
|
||||
### State after redirect
|
||||
|
||||
At this point:
|
||||
|
||||
- The browser is authenticated with the _provider_
|
||||
- The user's authorized permissions are recorded in an _oauth code_
|
||||
- The _provider_ knows that the given oauth client's requested permissions have been granted, but the client doesn't know this yet.
|
||||
- All requests so far have been made directly by the browser.
|
||||
No requests have originated at the client or provider.
|
||||
|
||||
### OAuth Client Handles Callback Request
|
||||
|
||||
Now we get to finish the OAuth process.
|
||||
Let's dig into what the oauth client does when it handles
|
||||
the oauth callback request with the
|
||||
|
||||
- The OAuth client receives the _code_ and makes an API request to the _provider_ to exchange the code for a real _token_.
|
||||
This is the first direct request between the OAuth _client_ and the _provider_.
|
||||
- Once the token is retrieved, the client _usually_
|
||||
makes a second API request to the _provider_
|
||||
to retrieve information about the owner of the token (the user).
|
||||
This is the step where behavior diverges for different OAuth providers.
|
||||
Up to this point, all oauth providers are the same, following the oauth specification.
|
||||
However, oauth does not define a standard for exchanging tokens for information about their owner or permissions ([OpenID Connect](https://openid.net/connect/) does that),
|
||||
so this step may be different for each OAuth provider.
|
||||
- Finally, the oauth client stores its own record that the user is authorized in a cookie.
|
||||
This could be the token itself, or any other appropriate representation of successful authentication.
|
||||
- Last of all, now that credentials have been established,
|
||||
the browser can be redirected to the _original_ URL where it started,
|
||||
to try the request again.
|
||||
If the client wasn't able to keep track of the original URL all this time
|
||||
(not always easy!),
|
||||
you might end up back at a default landing page instead of where you started the login process. This is frustrating!
|
||||
|
||||
😮💨 _phew_.
|
||||
|
||||
So that's _one_ OAuth process.
|
||||
|
||||
## Full sequence of OAuth in JupyterHub
|
||||
|
||||
Let's go through the above oauth process in JupyterHub,
|
||||
with specific examples of each HTTP request and what information is contained.
|
||||
For bonus points, we are using the double-oauth example of JupyterHub configured with GitHubOAuthenticator.
|
||||
|
||||
To disambiguate, we will call the OAuth process where JupyterHub is the **provider** "internal oauth,"
|
||||
and the one with JupyterHub as a **client** "external oauth."
|
||||
|
||||
Our starting point:
|
||||
|
||||
- a user's single-user server is running. Let's call them `danez`
|
||||
- jupyterhub is running with GitHub as an oauth provider (this means two full instances of oauth),
|
||||
- Danez has a fresh browser session with no cookies yet
|
||||
|
||||
First request:
|
||||
|
||||
- browser->single-user server running JupyterLab or Jupyter Classic
|
||||
- `GET /user/danez/notebooks/mynotebook.ipynb`
|
||||
- no credentials, so single-user server (as an oauth **client**) starts internal oauth process with JupyterHub (the **provider**)
|
||||
- response: 302 redirect -> `/hub/api/oauth2/authorize`
|
||||
with:
|
||||
- client-id=`jupyterhub-user-danez`
|
||||
- redirect-uri=`/user/danez/oauth_callback` (we'll come back later!)
|
||||
|
||||
Second request, following redirect:
|
||||
|
||||
- browser->jupyterhub
|
||||
- `GET /hub/api/oauth2/authorize`
|
||||
- no credentials, so jupyterhub starts external oauth process _with GitHub_
|
||||
- response: 302 redirect -> `https://github.com/login/oauth/authorize`
|
||||
with:
|
||||
- client-id=`jupyterhub-client-uuid`
|
||||
- redirect-uri=`/hub/oauth_callback` (we'll come back later!)
|
||||
|
||||
_pause_ This is where JupyterHub configuration comes into play.
|
||||
Recall, in this case JupyterHub is using:
|
||||
|
||||
```python
|
||||
c.JupyterHub.authenticator_class = 'github'
|
||||
```
|
||||
|
||||
That means authenticating a request to the Hub itself starts
|
||||
a _second_, external oauth process with GitHub as a provider.
|
||||
This external oauth process is optional, though.
|
||||
If you were using the default username+password PAMAuthenticator,
|
||||
this redirect would have been to `/hub/login` instead, to present the user
|
||||
with a login form.
|
||||
|
||||
Third request, following redirect:
|
||||
|
||||
- browser->GitHub
|
||||
- `GET https://github.com/login/oauth/authorize`
|
||||
|
||||
Here, GitHub prompts for login and asks for confirmation of authorization
|
||||
(more redirects if you aren't logged in to GitHub yet, but ultimately back to this `/authorize` URL).
|
||||
|
||||
After successful authorization
|
||||
(either by looking up a pre-existing authorization,
|
||||
or recording it via form submission)
|
||||
GitHub issues an **oauth code** and redirects to `/hub/oauth_callback?code=github-code`
|
||||
|
||||
Next request:
|
||||
|
||||
- browser->JupyterHub
|
||||
- `GET /hub/oauth_callback?code=github-code`
|
||||
|
||||
Inside the callback handler, JupyterHub makes two API requests:
|
||||
|
||||
The first:
|
||||
|
||||
- JupyterHub->GitHub
|
||||
- `POST https://github.com/login/oauth/access_token`
|
||||
- request made with oauth **code** from url parameter
|
||||
- response includes an access **token**
|
||||
|
||||
The second:
|
||||
|
||||
- JupyterHub->GitHub
|
||||
- `GET https://api.github.com/user`
|
||||
- request made with access **token** in the `Authorization` header
|
||||
- response is the user model, including username, email, etc.
|
||||
|
||||
Now the external oauth callback request completes with:
|
||||
|
||||
- set cookie on `/hub/` path, recording jupyterhub authentication so we don't need to do external oauth with GitHub again for a while
|
||||
- redirect -> `/hub/api/oauth2/authorize`
|
||||
|
||||
🎉 At this point, we have completed our first OAuth flow! 🎉
|
||||
|
||||
Now, we get our first repeated request:
|
||||
|
||||
- browser->jupyterhub
|
||||
- `GET /hub/api/oauth2/authorize`
|
||||
- this time with credentials,
|
||||
so jupyterhub either
|
||||
1. serves the internal authorization confirmation page, or
|
||||
2. automatically accepts authorization (shortcut taken when a user is visiting their own server)
|
||||
- redirect -> `/user/danez/oauth_callback?code=jupyterhub-code`
|
||||
|
||||
Here, we start the same oauth callback process as before, but at Danez's single-user server for the _internal_ oauth
|
||||
|
||||
- browser->single-user server
|
||||
- `GET /user/danez/oauth_callback`
|
||||
|
||||
(in handler)
|
||||
|
||||
Inside the internal oauth callback handler,
|
||||
Danez's server makes two API requests to JupyterHub:
|
||||
|
||||
The first:
|
||||
|
||||
- single-user server->JupyterHub
|
||||
- `POST /hub/api/oauth2/token`
|
||||
- request made with oauth code from url parameter
|
||||
- response includes an API token
|
||||
|
||||
The second:
|
||||
|
||||
- single-user server->JupyterHub
|
||||
- `GET /hub/api/user`
|
||||
- request made with token in the `Authorization` header
|
||||
- response is the user model, including username, groups, etc.
|
||||
|
||||
Finally completing `GET /user/danez/oauth_callback`:
|
||||
|
||||
- response sets cookie, storing encrypted access token
|
||||
- _finally_ redirects back to the original `/user/danez/notebooks/mynotebook.ipynb`
|
||||
|
||||
Final request:
|
||||
|
||||
- browser -> single-user server
|
||||
- `GET /user/danez/notebooks/mynotebook.ipynb`
|
||||
- encrypted jupyterhub token in cookie
|
||||
|
||||
To authenticate this request, the single token stored in the encrypted cookie is passed to the Hub for verification:
|
||||
|
||||
- single-user server -> Hub
|
||||
- `GET /hub/api/user`
|
||||
- browser's token in Authorization header
|
||||
- response: user model with name, groups, etc.
|
||||
|
||||
If the user model matches who should be allowed (e.g. Danez),
|
||||
then the request is allowed.
|
||||
See {doc}`../rbac/scopes` for how JupyterHub uses scopes to determine authorized access to servers and services.
|
||||
|
||||
_the end_
|
||||
|
||||
## Token caches and expiry
|
||||
|
||||
Because tokens represent information from an external source,
|
||||
they can become 'stale,'
|
||||
or the information they represent may no longer be accurate.
|
||||
For example: a user's GitHub account may no longer be authorized to use JupyterHub,
|
||||
that should ultimately propagate to revoking access and force logging in again.
|
||||
|
||||
To handle this, OAuth tokens and the various places they are stored can _expire_,
|
||||
which should have the same effect as no credentials,
|
||||
and trigger the authorization process again.
|
||||
|
||||
In JupyterHub's internal oauth, we have these layers of information that can go stale:
|
||||
|
||||
- The oauth client has a **cache** of Hub responses for tokens,
|
||||
so it doesn't need to make API requests to the Hub for every request it receives.
|
||||
This cache has an expiry of five minutes by default,
|
||||
and is governed by the configuration `HubAuth.cache_max_age` in the single-user server.
|
||||
- The internal oauth token is stored in a cookie, which has its own expiry (default: 14 days),
|
||||
governed by `JupyterHub.cookie_max_age_days`.
|
||||
- The internal oauth token can also itself expire,
|
||||
which is by default the same as the cookie expiry,
|
||||
since it makes sense for the token itself and the place it is stored to expire at the same time.
|
||||
This is governed by `JupyterHub.cookie_max_age_days` first,
|
||||
or can overridden by `JupyterHub.oauth_token_expires_in`.
|
||||
|
||||
That's all for _internal_ auth storage,
|
||||
but the information from the _external_ authentication provider
|
||||
(could be PAM or GitHub OAuth, etc.) can also expire.
|
||||
Authenticator configuration governs when JupyterHub needs to ask again,
|
||||
triggering the external login process anew before letting a user proceed.
|
||||
|
||||
- `jupyterhub-hub-login` cookie stores that a browser is authenticated with the Hub.
|
||||
This expires according to `JupyterHub.cookie_max_age_days` configuration,
|
||||
with a default of 14 days.
|
||||
The `jupyterhub-hub-login` cookie is encrypted with `JupyterHub.cookie_secret`
|
||||
configuration.
|
||||
- {meth}`.Authenticator.refresh_user` is a method to refresh a user's auth info.
|
||||
By default, it does nothing, but it can return an updated user model if a user's information has changed,
|
||||
or force a full login process again if needed.
|
||||
- {attr}`.Authenticator.auth_refresh_age` configuration governs how often
|
||||
`refresh_user()` will be called to check if a user must login again (default: 300 seconds).
|
||||
- {attr}`.Authenticator.refresh_pre_spawn` configuration governs whether
|
||||
`refresh_user()` should be called prior to spawning a server,
|
||||
to force fresh auth info when a server is launched (default: False).
|
||||
This can be useful when Authenticators pass access tokens to spawner environments, to ensure they aren't getting a stale token that's about to expire.
|
||||
|
||||
**So what happens when these things expire or get stale?**
|
||||
|
||||
- If the HubAuth **token response cache** expires,
|
||||
when a request is made with a token,
|
||||
the Hub is asked for the latest information about the token.
|
||||
This usually has no visible effect, since it is just refreshing a cache.
|
||||
If it turns out that the token itself has expired or been revoked,
|
||||
the request will be denied.
|
||||
- If the token has expired, but is still in the cookie:
|
||||
when the token response cache expires,
|
||||
the next time the server asks the hub about the token,
|
||||
no user will be identified and the internal oauth process begins again.
|
||||
- If the token _cookie_ expires, the next browser request will be made with no credentials,
|
||||
and the internal oauth process will begin again.
|
||||
This will usually have the form of a transparent redirect browsers won't notice.
|
||||
However, if this occurs on an API request in a long-lived page visit
|
||||
such as a JupyterLab session, the API request may fail and require
|
||||
a page refresh to get renewed credentials.
|
||||
- If the _JupyterHub_ cookie expires, the next time the browser makes a request to the Hub,
|
||||
the Hub's authorization process must begin again (e.g. login with GitHub).
|
||||
Hub cookie expiry on its own **does not** mean that a user can no longer access their single-user server!
|
||||
- If credentials from the upstream provider (e.g. GitHub) become stale or outdated,
|
||||
these will not be refreshed until/unless `refresh_user` is called
|
||||
_and_ `refresh_user()` on the given Authenticator is implemented to perform such a check.
|
||||
At this point, few Authenticators implement `refresh_user` to support this feature.
|
||||
If your Authenticator does not or cannot implement `refresh_user`,
|
||||
the only way to force a check is to reset the `JupyterHub.cookie_secret` encryption key,
|
||||
which invalidates the `jupyterhub-hub-login` cookie for all users.
|
||||
|
||||
### Logging out
|
||||
|
||||
Logging out of JupyterHub means clearing and revoking many of these credentials:
|
||||
|
||||
- The `jupyterhub-hub-login` cookie is revoked, meaning the next request to the Hub itself will require a new login.
|
||||
- The token stored in the `jupyterhub-user-username` cookie for the single-user server
|
||||
will be revoked, based on its associaton with `jupyterhub-session-id`, but the _cookie itself cannot be cleared at this point_
|
||||
- The shared `jupyterhub-session-id` is cleared, which ensures that the HubAuth **token response cache** will not be used,
|
||||
and the next request with the expired token will ask the Hub, which will inform the single-user server that the token has expired
|
||||
|
||||
## Extra bits
|
||||
|
||||
(two-tokens)=
|
||||
|
||||
### A tale of two tokens
|
||||
|
||||
**TODO**: discuss API token issued to server at startup ($JUPYTERHUB_API_TOKEN)
|
||||
and oauth-issued token in the cookie,
|
||||
and some details of how JupyterLab currently deals with that.
|
||||
They are different, and JupyterLab should be making requests using the token from the cookie,
|
||||
not the token from the server,
|
||||
but that is not currently the case.
|
||||
|
||||
### Redirect loops
|
||||
|
||||
In general, an authenticated web endpoint has this behavior,
|
||||
based on the authentication/authorization state of the browser:
|
||||
|
||||
- If authorized, allow the request to happen
|
||||
- If authenticated (I know who you are) but not authorized (you are not allowed), fail with a 403 permission denied error
|
||||
- If not authenticated, start a redirect process to establish authorization,
|
||||
which should end in a redirect back to the original URL to try again.
|
||||
**This is why problems in authentication result in redirect loops!**
|
||||
If the second request fails to detect the authentication that should have been established during the redirect,
|
||||
it will start the authentication redirect process over again,
|
||||
and keep redirecting in a loop until the browser balks.
|
@@ -86,10 +86,19 @@ Hub-Managed Service would include:
|
||||
This example would be configured as follows in `jupyterhub_config.py`:
|
||||
|
||||
```python
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "idle-culler",
|
||||
"scopes": [
|
||||
"read:users:activity", # read user last_activity
|
||||
"users:servers", # start and stop servers
|
||||
# 'admin:users' # needed if culling idle users as well
|
||||
]
|
||||
}
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'idle-culler',
|
||||
'admin': True,
|
||||
'command': [sys.executable, '-m', 'jupyterhub_idle_culler', '--timeout=3600']
|
||||
}
|
||||
]
|
||||
@@ -114,6 +123,7 @@ JUPYTERHUB_BASE_URL: Base URL of the Hub (https://mydomain[:port]/)
|
||||
JUPYTERHUB_SERVICE_PREFIX: URL path prefix of this service (/services/:service-name/)
|
||||
JUPYTERHUB_SERVICE_URL: Local URL where the service is expected to be listening.
|
||||
Only for proxied web services.
|
||||
JUPYTERHUB_OAUTH_SCOPES: JSON-serialized list of scopes to use for allowing access to the service.
|
||||
```
|
||||
|
||||
For the previous 'cull idle' Service example, these environment variables
|
||||
@@ -231,50 +241,8 @@ service. See the `service-whoami-flask` example in the
|
||||
[JupyterHub GitHub repo](https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-whoami-flask)
|
||||
for more details.
|
||||
|
||||
```python
|
||||
from functools import wraps
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import Flask, redirect, request, Response
|
||||
|
||||
from jupyterhub.services.auth import HubOAuth
|
||||
|
||||
prefix = os.environ.get('JUPYTERHUB_SERVICE_PREFIX', '/')
|
||||
|
||||
auth = HubOAuth(
|
||||
api_token=os.environ['JUPYTERHUB_API_TOKEN'],
|
||||
cache_max_age=60,
|
||||
)
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
|
||||
def authenticated(f):
|
||||
"""Decorator for authenticating with the Hub"""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
token = request.headers.get(auth.auth_header_name)
|
||||
if token:
|
||||
user = auth.user_for_token(token)
|
||||
else:
|
||||
user = None
|
||||
if user:
|
||||
return f(user, *args, **kwargs)
|
||||
else:
|
||||
# redirect to login url on failed auth
|
||||
return redirect(auth.login_url + '?next=%s' % quote(request.path))
|
||||
return decorated
|
||||
|
||||
|
||||
@app.route(prefix)
|
||||
@authenticated
|
||||
def whoami(user):
|
||||
return Response(
|
||||
json.dumps(user, indent=1, sort_keys=True),
|
||||
mimetype='application/json',
|
||||
)
|
||||
```{literalinclude} ../../../examples/service-whoami-flask/whoami-flask.py
|
||||
:language: python
|
||||
```
|
||||
|
||||
### Authenticating tornado services with JupyterHub
|
||||
@@ -315,25 +283,38 @@ undefined, then any user will be allowed.
|
||||
If you don't want to use the reference implementation
|
||||
(e.g. you find the implementation a poor fit for your Flask app),
|
||||
you can implement authentication via the Hub yourself.
|
||||
We recommend looking at the [`HubAuth`][hubauth] class implementation for reference,
|
||||
JupyterHub is a standard OAuth2 provider,
|
||||
so you can use any OAuth 2 client implementation appropriate for your toolkit.
|
||||
See the [FastAPI example][] for an example of using JupyterHub as an OAuth provider with [FastAPI][],
|
||||
without using any code imported from JupyterHub.
|
||||
|
||||
On completion of OAuth, you will have an access token for JupyterHub,
|
||||
which can be used to identify the user and the permissions (scopes)
|
||||
the user has authorized for your service.
|
||||
|
||||
You will only get to this stage if the user has the required `access:services!service=$service-name` scope.
|
||||
|
||||
To retrieve the user model for the token, make a request to `GET /hub/api/user` with the token in the Authorization header.
|
||||
For example, using flask:
|
||||
|
||||
```{literal-include} ../../../examples/service-whoami-flask/whoami-flask.py
|
||||
:language: python
|
||||
```
|
||||
|
||||
We recommend looking at the [`HubOAuth`][huboauth] class implementation for reference,
|
||||
and taking note of the following process:
|
||||
|
||||
1. retrieve the cookie `jupyterhub-services` from the request.
|
||||
2. Make an API request `GET /hub/api/authorizations/cookie/jupyterhub-services/cookie-value`,
|
||||
where cookie-value is the url-encoded value of the `jupyterhub-services` cookie.
|
||||
This request must be authenticated with a Hub API token in the `Authorization` header,
|
||||
for example using the `api_token` from your [external service's configuration](#externally-managed-services).
|
||||
1. retrieve the token from the request.
|
||||
2. Make an API request `GET /hub/api/user`,
|
||||
with the token in the `Authorization` header.
|
||||
|
||||
For example, with [requests][]:
|
||||
|
||||
```python
|
||||
r = requests.get(
|
||||
'/'.join(["http://127.0.0.1:8081/hub/api",
|
||||
"authorizations/cookie/jupyterhub-services",
|
||||
quote(encrypted_cookie, safe=''),
|
||||
]),
|
||||
"http://127.0.0.1:8081/hub/api/user",
|
||||
headers = {
|
||||
'Authorization' : 'token %s' % api_token,
|
||||
'Authorization' : f'token {api_token}',
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
@@ -342,13 +323,27 @@ and taking note of the following process:
|
||||
|
||||
3. On success, the reply will be a JSON model describing the user:
|
||||
|
||||
```json
|
||||
```python
|
||||
{
|
||||
"name": "inara",
|
||||
"groups": ["serenity", "guild"]
|
||||
# groups may be omitted, depending on permissions
|
||||
"groups": ["serenity", "guild"],
|
||||
# scopes is new in JupyterHub 2.0
|
||||
"scopes": [
|
||||
"access:services",
|
||||
"read:users:name",
|
||||
"read:users!user=inara",
|
||||
"..."
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `scopes` field can be used to manage access.
|
||||
Note: a user will have access to a service to complete oauth access to the service for the first time.
|
||||
Individual permissions may be revoked at any later point without revoking the token,
|
||||
in which case the `scopes` field in this model should be checked on each access.
|
||||
The default required scopes for access are available from `hub_auth.oauth_scopes` or `$JUPYTERHUB_OAUTH_SCOPES`.
|
||||
|
||||
An example of using an Externally-Managed Service and authentication is
|
||||
in [nbviewer README][nbviewer example] section on securing the notebook viewer,
|
||||
and an example of its configuration is found [here](https://github.com/jupyter/nbviewer/blob/ed942b10a52b6259099e2dd687930871dc8aac22/nbviewer/providers/base.py#L95).
|
||||
@@ -357,9 +352,10 @@ section on securing the notebook viewer.
|
||||
|
||||
[requests]: http://docs.python-requests.org/en/master/
|
||||
[services_auth]: ../api/services.auth.html
|
||||
[hubauth]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth
|
||||
[hubauth.user_for_cookie]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_cookie
|
||||
[huboauth]: ../api/services.auth.html#jupyterhub.services.auth.HubOAuth
|
||||
[hubauth.user_for_token]: ../api/services.auth.html#jupyterhub.services.auth.HubAuth.user_for_token
|
||||
[hubauthenticated]: ../api/services.auth.html#jupyterhub.services.auth.HubAuthenticated
|
||||
[nbviewer example]: https://github.com/jupyter/nbviewer#securing-the-notebook-viewer
|
||||
[fastapi example]: https://github.com/jupyterhub/jupyterhub/tree/HEAD/examples/service-fastapi
|
||||
[fastapi]: https://fastapi.tiangolo.com
|
||||
[jupyterhub_idle_culler]: https://github.com/jupyterhub/jupyterhub-idle-culler
|
||||
|
@@ -1,10 +1,10 @@
|
||||
# Configuration file for jupyterhub (postgres example).
|
||||
|
||||
c = get_config()
|
||||
c = get_config() # noqa
|
||||
|
||||
# Add some users.
|
||||
c.JupyterHub.admin_users = {'rhea'}
|
||||
c.Authenticator.whitelist = {'ganymede', 'io', 'rhea'}
|
||||
c.Authenticator.allowed_users = {'ganymede', 'io', 'rhea'}
|
||||
|
||||
# These environment variables are automatically supplied by the linked postgres
|
||||
# container.
|
||||
|
@@ -6,15 +6,17 @@ that appear when JupyterHub renders pages.
|
||||
To run the service as a hub-managed service simply include in your JupyterHub
|
||||
configuration file something like:
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'announcement',
|
||||
'url': 'http://127.0.0.1:8888',
|
||||
'command': [sys.executable, "-m", "announcement"],
|
||||
}
|
||||
]
|
||||
```python
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'announcement',
|
||||
'url': 'http://127.0.0.1:8888',
|
||||
'command': [sys.executable, "-m", "announcement", "--port", "8888"],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
This starts the announcements service up at `/services/announcement` when
|
||||
This starts the announcements service up at `/services/announcement/` when
|
||||
JupyterHub launches. By default the announcement text is empty.
|
||||
|
||||
The `announcement` module has a configurable port (default 8888) and an API
|
||||
@@ -23,15 +25,28 @@ that environment variable is set or `/` if it is not.
|
||||
|
||||
## Managing the Announcement
|
||||
|
||||
Admin users can set the announcement text with an API token:
|
||||
Users with permission can set the announcement text with an API token:
|
||||
|
||||
$ curl -X POST -H "Authorization: token <token>" \
|
||||
-d '{"announcement":"JupyterHub will be upgraded on August 14!"}' \
|
||||
https://.../services/announcement
|
||||
https://.../services/announcement/
|
||||
|
||||
To grant permission, add a role (JupyterHub 2.0) with access to the announcement service:
|
||||
|
||||
```python
|
||||
# grant the 'announcer' permission to access the announcement service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "announcers",
|
||||
"users": ["announcer"], # or groups
|
||||
"scopes": ["access:services!service=announcement"],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Anyone can read the announcement:
|
||||
|
||||
$ curl https://.../services/announcement | python -m json.tool
|
||||
$ curl https://.../services/announcement/ | python -m json.tool
|
||||
{
|
||||
announcement: "JupyterHub will be upgraded on August 14!",
|
||||
timestamp: "...",
|
||||
@@ -41,10 +56,11 @@ Anyone can read the announcement:
|
||||
The time the announcement was posted is recorded in the `timestamp` field and
|
||||
the user who posted the announcement is recorded in the `user` field.
|
||||
|
||||
To clear the announcement text, just DELETE. Only admin users can do this.
|
||||
To clear the announcement text, send a DELETE request.
|
||||
This has the same permission requirement.
|
||||
|
||||
$ curl -X POST -H "Authorization: token <token>" \
|
||||
https://.../services/announcement
|
||||
$ curl -X DELETE -H "Authorization: token <token>" \
|
||||
https://.../services/announcement/
|
||||
|
||||
## Seeing the Announcement in JupyterHub
|
||||
|
||||
|
@@ -13,9 +13,6 @@ from jupyterhub.services.auth import HubAuthenticated
|
||||
class AnnouncementRequestHandler(HubAuthenticated, web.RequestHandler):
|
||||
"""Dynamically manage page announcements"""
|
||||
|
||||
hub_users = []
|
||||
allow_admin = True
|
||||
|
||||
def initialize(self, storage):
|
||||
"""Create storage for announcement text"""
|
||||
self.storage = storage
|
||||
|
@@ -2,11 +2,18 @@ import sys
|
||||
|
||||
# To run the announcement service managed by the hub, add this.
|
||||
|
||||
port = 9999
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'announcement',
|
||||
'url': 'http://127.0.0.1:8888',
|
||||
'command': [sys.executable, "-m", "announcement"],
|
||||
'url': f'http://127.0.0.1:{port}',
|
||||
'command': [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"announcement",
|
||||
'--port',
|
||||
str(port),
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
@@ -14,3 +21,19 @@ c.JupyterHub.services = [
|
||||
# for an example of how to do this.
|
||||
|
||||
c.JupyterHub.template_paths = ["templates"]
|
||||
|
||||
c.Authenticator.allowed_users = {"announcer", "otheruser"}
|
||||
|
||||
# grant the 'announcer' permission to access the announcement service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "announcers",
|
||||
"users": ["announcer"],
|
||||
"scopes": ["access:services!service=announcement"],
|
||||
}
|
||||
]
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -16,6 +16,7 @@ jupyterhub --ip=127.0.0.1
|
||||
```
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/fastapi or http://127.0.0.1:8000/services/fastapi/docs
|
||||
Login with username 'test-user' and any password.
|
||||
|
||||
3. Try interacting programmatically. If you create a new token in your control panel or pull out the `JUPYTERHUB_API_TOKEN` in the single user environment, you can skip the third step here.
|
||||
|
||||
@@ -24,10 +25,10 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/
|
||||
{"Hello":"World"}
|
||||
|
||||
$ curl -X GET http://127.0.0.1:8000/services/fastapi/me
|
||||
{"detail":"Must login with token parameter, cookie, or header"}
|
||||
{"detail":"Must login with token parameter, or Authorization bearer header"}
|
||||
|
||||
$ curl -X POST http://127.0.0.1:8000/hub/api/authorizations/token \
|
||||
-d '{"username": "myname", "password": "mypasswd!"}' \
|
||||
$ curl -X POST http://127.0.0.1:8000/hub/api/users/test-user/tokens \
|
||||
-d '{"auth": {"username": "test-user", "password": "mypasswd!"}}' \
|
||||
| jq '.token'
|
||||
"3fee13ce6d2845da9bd5f2c2170d3428"
|
||||
|
||||
@@ -35,13 +36,18 @@ $ curl -X GET http://127.0.0.1:8000/services/fastapi/me \
|
||||
-H "Authorization: Bearer 3fee13ce6d2845da9bd5f2c2170d3428" \
|
||||
| jq .
|
||||
{
|
||||
"name": "myname",
|
||||
"name": "test-user",
|
||||
"admin": false,
|
||||
"groups": [],
|
||||
"server": null,
|
||||
"pending": null,
|
||||
"last_activity": "2021-04-07T18:05:11.587638+00:00",
|
||||
"servers": null
|
||||
"last_activity": "2021-05-21T09:13:00.514309+00:00",
|
||||
"servers": null,
|
||||
"scopes": [
|
||||
"access:services",
|
||||
"access:users:servers!user=test-user",
|
||||
"...",
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
@@ -22,11 +23,12 @@ class Server(BaseModel):
|
||||
class User(BaseModel):
|
||||
name: str
|
||||
admin: bool
|
||||
groups: List[str]
|
||||
groups: Optional[List[str]]
|
||||
server: Optional[str]
|
||||
pending: Optional[str]
|
||||
last_activity: datetime
|
||||
servers: Optional[List[Server]]
|
||||
servers: Optional[Dict[str, Server]]
|
||||
scopes: List[str]
|
||||
|
||||
|
||||
# https://stackoverflow.com/questions/64501193/fastapi-how-to-use-httpexception-in-responses
|
||||
|
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -27,6 +28,12 @@ auth_by_header = OAuth2AuthorizationCodeBearer(
|
||||
### our client_secret (JUPYTERHUB_API_TOKEN) and that code to get an
|
||||
### access_token, which it returns to browser, which places in Authorization header.
|
||||
|
||||
if os.environ.get("JUPYTERHUB_OAUTH_SCOPES"):
|
||||
# typically ["access:services", "access:services!service=$service_name"]
|
||||
access_scopes = json.loads(os.environ["JUPYTERHUB_OAUTH_SCOPES"])
|
||||
else:
|
||||
access_scopes = ["access:services"]
|
||||
|
||||
### For consideration: optimize performance with a cache instead of
|
||||
### always hitting the Hub api?
|
||||
async def get_current_user(
|
||||
@@ -58,4 +65,15 @@ async def get_current_user(
|
||||
},
|
||||
)
|
||||
user = User(**resp.json())
|
||||
return user
|
||||
if any(scope in user.scopes for scope in access_scopes):
|
||||
return user
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail={
|
||||
"msg": f"User not authorized: {user.name}",
|
||||
"request_url": str(resp.request.url),
|
||||
"token": token,
|
||||
"user": resp.json(),
|
||||
},
|
||||
)
|
||||
|
@@ -24,8 +24,21 @@ c.JupyterHub.services = [
|
||||
"name": service_name,
|
||||
"url": "http://127.0.0.1:10202",
|
||||
"command": ["uvicorn", "app:app", "--port", "10202"],
|
||||
"admin": True,
|
||||
"oauth_redirect_uri": oauth_redirect_uri,
|
||||
"environment": {"PUBLIC_HOST": public_host},
|
||||
}
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
# grant all users access to services
|
||||
"scopes": ["self", "access:services"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# dummy for testing, create test-user
|
||||
c.Authenticator.allowed_users = {"test-user"}
|
||||
c.JupyterHub.authenticator_class = "dummy"
|
||||
c.JupyterHub.spawner_class = "simple"
|
||||
|
@@ -1,15 +1,35 @@
|
||||
# our user list
|
||||
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
|
||||
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
service_name = 'shared-notebook'
|
||||
service_port = 9999
|
||||
group_name = 'shared'
|
||||
|
||||
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
|
||||
# ellisonbg and willingc are in a group that will access the shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
|
||||
|
||||
# start the notebook server as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'shared-notebook',
|
||||
'url': 'http://127.0.0.1:9999',
|
||||
'api_token': 'super-secret',
|
||||
'name': service_name,
|
||||
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||
'api_token': 'c3a29e5d386fd7c9aa1e8fe9d41c282ec8b',
|
||||
}
|
||||
]
|
||||
|
||||
# This "role assignment" is what grants members of the group
|
||||
# access to the service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "shared-notebook",
|
||||
"groups": [group_name],
|
||||
"scopes": [f"access:services!service={service_name}"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -1,9 +1,11 @@
|
||||
#!/bin/bash -l
|
||||
set -e
|
||||
|
||||
export JUPYTERHUB_API_TOKEN=super-secret
|
||||
# these must match the values in jupyterhub_config.py
|
||||
export JUPYTERHUB_API_TOKEN=c3a29e5d386fd7c9aa1e8fe9d41c282ec8b
|
||||
export JUPYTERHUB_SERVICE_URL=http://127.0.0.1:9999
|
||||
export JUPYTERHUB_SERVICE_NAME=shared-notebook
|
||||
export JUPYTERHUB_SERVICE_PREFIX="/services/${JUPYTERHUB_SERVICE_NAME}/"
|
||||
export JUPYTERHUB_CLIENT_ID="service-${JUPYTERHUB_SERVICE_NAME}"
|
||||
|
||||
jupyterhub-singleuser \
|
||||
--group='shared'
|
||||
jupyterhub-singleuser
|
||||
|
@@ -1,19 +1,35 @@
|
||||
# our user list
|
||||
c.Authenticator.whitelist = ['minrk', 'ellisonbg', 'willingc']
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {'shared': ['ellisonbg', 'willingc']}
|
||||
c.Authenticator.allowed_users = ['minrk', 'ellisonbg', 'willingc']
|
||||
|
||||
service_name = 'shared-notebook'
|
||||
service_port = 9999
|
||||
group_name = 'shared'
|
||||
|
||||
# ellisonbg and willingc have access to a shared server:
|
||||
|
||||
c.JupyterHub.load_groups = {group_name: ['ellisonbg', 'willingc']}
|
||||
|
||||
# start the notebook server as a service
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': service_name,
|
||||
'url': 'http://127.0.0.1:{}'.format(service_port),
|
||||
'command': ['jupyterhub-singleuser', '--group=shared', '--debug'],
|
||||
'command': ['jupyterhub-singleuser', '--debug'],
|
||||
}
|
||||
]
|
||||
|
||||
# This "role assignment" is what grants members of the group
|
||||
# access to the service
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "shared-notebook",
|
||||
"groups": [group_name],
|
||||
"scopes": [f"access:services!service={service_name}"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -2,15 +2,15 @@
|
||||
|
||||
Uses `jupyterhub.services.HubAuthenticated` to authenticate requests with the Hub.
|
||||
|
||||
There is an implementation each of cookie-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
|
||||
There is an implementation each of api-token-based `HubAuthenticated` and OAuth-based `HubOAuthenticated`.
|
||||
|
||||
## Run
|
||||
|
||||
1. Launch JupyterHub and the `whoami service` with
|
||||
1. Launch JupyterHub and the `whoami` services with
|
||||
|
||||
jupyterhub --ip=127.0.0.1
|
||||
|
||||
2. Visit http://127.0.0.1:8000/services/whoami or http://127.0.0.1:8000/services/whoami-oauth
|
||||
2. Visit http://127.0.0.1:8000/services/whoami-oauth
|
||||
|
||||
After logging in with your local-system credentials, you should see a JSON dump of your user info:
|
||||
|
||||
@@ -24,15 +24,65 @@ After logging in with your local-system credentials, you should see a JSON dump
|
||||
}
|
||||
```
|
||||
|
||||
The `whoami-api` service powered by the base `HubAuthenticated` class only supports token-authenticated API requests,
|
||||
not browser visits, because it does not implement OAuth. Visit it by requesting an api token from the tokens page,
|
||||
and making a direct request:
|
||||
|
||||
```bash
|
||||
$ curl -H "Authorization: token 8630bbd8ef064c48b22c7f122f0cd8ad" http://127.0.0.1:8000/services/whoami-api/ | jq .
|
||||
{
|
||||
"admin": false,
|
||||
"created": "2021-05-21T09:47:41.299400Z",
|
||||
"groups": [],
|
||||
"kind": "user",
|
||||
"last_activity": "2021-05-21T09:49:08.290745Z",
|
||||
"name": "test",
|
||||
"pending": null,
|
||||
"roles": [
|
||||
"user"
|
||||
],
|
||||
"scopes": [
|
||||
"access:services",
|
||||
"access:users:servers!user=test",
|
||||
"read:users!user=test",
|
||||
"read:users:activity!user=test",
|
||||
"read:users:groups!user=test",
|
||||
"read:users:name!user=test",
|
||||
"read:users:servers!user=test",
|
||||
"read:users:tokens!user=test",
|
||||
"users!user=test",
|
||||
"users:activity!user=test",
|
||||
"users:groups!user=test",
|
||||
"users:name!user=test",
|
||||
"users:servers!user=test",
|
||||
"users:tokens!user=test"
|
||||
],
|
||||
"server": null
|
||||
}
|
||||
```
|
||||
|
||||
This relies on the Hub starting the whoami services, via config (see [jupyterhub_config.py](./jupyterhub_config.py)).
|
||||
|
||||
You may set the `hub_users` configuration in the service script
|
||||
to restrict access to the service to a whitelist of allowed users.
|
||||
By default, any authenticated user is allowed.
|
||||
To govern access to the services, create **roles** with the scope `access:services!service=$service-name`,
|
||||
and assign users to the scope.
|
||||
|
||||
The jupyterhub_config.py grants access for all users to all services via the default 'user' role, with:
|
||||
|
||||
```python
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
# grant all users access to all services
|
||||
"scopes": ["access:services", "self"],
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
A similar service could be run externally, by setting the JupyterHub service environment variables:
|
||||
|
||||
JUPYTERHUB_API_TOKEN
|
||||
JUPYTERHUB_SERVICE_PREFIX
|
||||
JUPYTERHUB_OAUTH_SCOPES
|
||||
JUPYTERHUB_CLIENT_ID # for whoami-oauth only
|
||||
|
||||
or instantiating and configuring a HubAuth object yourself, and attaching it as `self.hub_auth` in your HubAuthenticated handlers.
|
||||
|
@@ -2,7 +2,7 @@ import sys
|
||||
|
||||
c.JupyterHub.services = [
|
||||
{
|
||||
'name': 'whoami',
|
||||
'name': 'whoami-api',
|
||||
'url': 'http://127.0.0.1:10101',
|
||||
'command': [sys.executable, './whoami.py'],
|
||||
},
|
||||
@@ -12,3 +12,16 @@ c.JupyterHub.services = [
|
||||
'command': [sys.executable, './whoami-oauth.py'],
|
||||
},
|
||||
]
|
||||
|
||||
c.JupyterHub.load_roles = [
|
||||
{
|
||||
"name": "user",
|
||||
# grant all users access to all services
|
||||
"scopes": ["access:services", "self"],
|
||||
}
|
||||
]
|
||||
|
||||
# dummy spawner and authenticator for testing, don't actually use these!
|
||||
c.JupyterHub.authenticator_class = 'dummy'
|
||||
c.JupyterHub.spawner_class = 'simple'
|
||||
c.JupyterHub.ip = '127.0.0.1' # let's just run on localhost while dummy auth is enabled
|
||||
|
@@ -1,6 +1,6 @@
|
||||
"""An example service authenticating with the Hub.
|
||||
|
||||
This example service serves `/services/whoami/`,
|
||||
This example service serves `/services/whoami-oauth/`,
|
||||
authenticated with the Hub,
|
||||
showing the user their own info.
|
||||
"""
|
||||
@@ -20,13 +20,6 @@ from jupyterhub.utils import url_path_join
|
||||
|
||||
|
||||
class WhoAmIHandler(HubOAuthenticated, RequestHandler):
|
||||
# hub_users can be a set of users who are allowed to access the service
|
||||
# `getuser()` here would mean only the user who started the service
|
||||
# can access the service:
|
||||
|
||||
# from getpass import getuser
|
||||
# hub_users = {getuser()}
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
user_model = self.get_current_user()
|
||||
|
@@ -1,6 +1,8 @@
|
||||
"""An example service authenticating with the Hub.
|
||||
|
||||
This serves `/services/whoami/`, authenticated with the Hub, showing the user their own info.
|
||||
This serves `/services/whoami-api/`, authenticated with the Hub, showing the user their own info.
|
||||
|
||||
HubAuthenticated only supports token-based access.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
@@ -16,13 +18,6 @@ from jupyterhub.services.auth import HubAuthenticated
|
||||
|
||||
|
||||
class WhoAmIHandler(HubAuthenticated, RequestHandler):
|
||||
# hub_users can be a set of users who are allowed to access the service
|
||||
# `getuser()` here would mean only the user who started the service
|
||||
# can access the service:
|
||||
|
||||
# from getpass import getuser
|
||||
# hub_users = {getuser()}
|
||||
|
||||
@authenticated
|
||||
def get(self):
|
||||
user_model = self.get_current_user()
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""rbac
|
||||
"""
|
||||
rbac changes for jupyterhub 2.0
|
||||
|
||||
Revision ID: 833da8570507
|
||||
Revises: 4dc2d5a8c53c
|
||||
@@ -14,8 +15,41 @@ depends_on = None
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
from jupyterhub import orm
|
||||
|
||||
|
||||
naming_convention = orm.meta.naming_convention
|
||||
|
||||
|
||||
def upgrade():
|
||||
# associate spawners and services with their oauth clients
|
||||
# op.add_column(
|
||||
# 'services', sa.Column('oauth_client_id', sa.Unicode(length=255), nullable=True)
|
||||
# )
|
||||
for table_name in ('services', 'spawners'):
|
||||
column_name = "oauth_client_id"
|
||||
target_table = "oauth_clients"
|
||||
target_column = "identifier"
|
||||
with op.batch_alter_table(
|
||||
table_name,
|
||||
schema=None,
|
||||
) as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column('oauth_client_id', sa.Unicode(length=255), nullable=True),
|
||||
)
|
||||
batch_op.create_foreign_key(
|
||||
naming_convention["fk"]
|
||||
% dict(
|
||||
table_name=table_name,
|
||||
column_0_name=column_name,
|
||||
referred_table_name=target_table,
|
||||
),
|
||||
target_table,
|
||||
[column_name],
|
||||
[target_column],
|
||||
ondelete='SET NULL',
|
||||
)
|
||||
|
||||
# FIXME, maybe: currently drops all api tokens and forces recreation!
|
||||
# this ensures a consistent database, but requires:
|
||||
# 1. all servers to be stopped for upgrade (maybe unavoidable anyway)
|
||||
@@ -33,11 +67,32 @@ def upgrade():
|
||||
|
||||
|
||||
def downgrade():
|
||||
for table_name in ('services', 'spawners'):
|
||||
column_name = "oauth_client_id"
|
||||
target_table = "oauth_clients"
|
||||
target_column = "identifier"
|
||||
|
||||
with op.batch_alter_table(
|
||||
table_name,
|
||||
schema=None,
|
||||
naming_convention=orm.meta.naming_convention,
|
||||
) as batch_op:
|
||||
batch_op.drop_constraint(
|
||||
naming_convention["fk"]
|
||||
% dict(
|
||||
table_name=table_name,
|
||||
column_0_name=column_name,
|
||||
referred_table_name=target_table,
|
||||
),
|
||||
type_='foreignkey',
|
||||
)
|
||||
batch_op.drop_column(column_name)
|
||||
|
||||
# delete OAuth tokens for non-jupyterhub clients
|
||||
# drop new columns from api tokens
|
||||
op.drop_constraint(None, 'api_tokens', type_='foreignkey')
|
||||
op.drop_column('api_tokens', 'session_id')
|
||||
op.drop_column('api_tokens', 'client_id')
|
||||
# op.drop_constraint(None, 'api_tokens', type_='foreignkey')
|
||||
# op.drop_column('api_tokens', 'session_id')
|
||||
# op.drop_column('api_tokens', 'client_id')
|
||||
|
||||
# FIXME: only drop tokens whose client id is not 'jupyterhub'
|
||||
# until then, drop all tokens
|
||||
|
@@ -35,8 +35,8 @@ class TokenAPIHandler(APIHandler):
|
||||
if owner:
|
||||
# having a token means we should be able to read the owner's model
|
||||
# (this is the only thing this handler is for)
|
||||
self.raw_scopes.update(scopes.identify_scopes(owner))
|
||||
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
||||
self.expanded_scopes.update(scopes.identify_scopes(owner))
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
|
||||
# record activity whenever we see a token
|
||||
now = orm_token.last_activity = datetime.utcnow()
|
||||
@@ -216,6 +216,31 @@ class OAuthAuthorizeHandler(OAuthHandler, BaseHandler):
|
||||
)
|
||||
credentials = self.add_credentials(credentials)
|
||||
client = self.oauth_provider.fetch_by_client_id(credentials['client_id'])
|
||||
allowed = False
|
||||
|
||||
# check for access to target resource
|
||||
if client.spawner:
|
||||
scope_filter = self.get_scope_filter("access:users:servers")
|
||||
allowed = scope_filter(client.spawner, kind='server')
|
||||
elif client.service:
|
||||
scope_filter = self.get_scope_filter("access:services")
|
||||
allowed = scope_filter(client.service, kind='service')
|
||||
else:
|
||||
# client is not associated with a service or spawner.
|
||||
# This shouldn't happen, but it might if this is a stale or forged request
|
||||
# from a service or spawner that's since been deleted
|
||||
self.log.error(
|
||||
f"OAuth client {client} has no service or spawner, cannot resolve scopes."
|
||||
)
|
||||
raise web.HTTPError(500, "OAuth configuration error")
|
||||
|
||||
if not allowed:
|
||||
self.log.error(
|
||||
f"User {self.current_user} not allowed to access {client.description}"
|
||||
)
|
||||
raise web.HTTPError(
|
||||
403, f"You do not have permission to access {client.description}"
|
||||
)
|
||||
if not self.needs_oauth_confirm(self.current_user, client):
|
||||
self.log.debug(
|
||||
"Skipping oauth confirmation for %s accessing %s",
|
||||
|
@@ -1,19 +1,14 @@
|
||||
"""Base API handlers"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import functools
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from http.client import responses
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from .. import scopes
|
||||
from ..handlers import BaseHandler
|
||||
from ..user import User
|
||||
from ..utils import isoformat
|
||||
from ..utils import url_path_join
|
||||
|
||||
@@ -66,46 +61,6 @@ class APIHandler(BaseHandler):
|
||||
return False
|
||||
return True
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_scope_filter(self, req_scope):
|
||||
"""Produce a filter for `*ListAPIHandlers* so that GET method knows which models to return.
|
||||
Filter is a callable that takes a resource name and outputs true or false"""
|
||||
|
||||
def no_access(orm_resource, kind):
|
||||
return False
|
||||
|
||||
if req_scope not in self.parsed_scopes:
|
||||
return no_access
|
||||
sub_scope = self.parsed_scopes[req_scope]
|
||||
|
||||
def has_access_to(orm_resource, kind):
|
||||
"""
|
||||
param orm_resource: User or Service or Group or spawner
|
||||
param kind: 'user' or 'service' or 'group' or 'server'.
|
||||
"""
|
||||
if sub_scope == scopes.Scope.ALL:
|
||||
return True
|
||||
elif orm_resource.name in sub_scope.get(kind, []):
|
||||
return True
|
||||
if kind == 'server':
|
||||
server_format = f"{orm_resource.user.name}/{orm_resource.name}"
|
||||
if server_format in sub_scope.get(kind, []):
|
||||
return True
|
||||
# Fall back on checking if we have user access
|
||||
if orm_resource.user.name in sub_scope.get('user', []):
|
||||
return True
|
||||
# Fall back on checking if we have group access for this user
|
||||
orm_resource = orm_resource.user
|
||||
kind = 'user'
|
||||
if kind == 'user' and 'group' in sub_scope:
|
||||
group_names = {group.name for group in orm_resource.groups}
|
||||
user_in_group = bool(group_names & set(sub_scope['group']))
|
||||
if user_in_group:
|
||||
return True
|
||||
return False
|
||||
|
||||
return has_access_to
|
||||
|
||||
def get_current_user_cookie(self):
|
||||
"""Override get_user_cookie to check Referer header"""
|
||||
cookie_user = super().get_current_user_cookie()
|
||||
@@ -213,7 +168,8 @@ class APIHandler(BaseHandler):
|
||||
'last_activity': isoformat(token.last_activity),
|
||||
'expires_at': isoformat(token.expires_at),
|
||||
'note': token.note,
|
||||
'oauth_client': token.client.description or token.client.client_id,
|
||||
'oauth_client': token.oauth_client.description
|
||||
or token.oauth_client.identifier,
|
||||
}
|
||||
return model
|
||||
|
||||
@@ -255,7 +211,7 @@ class APIHandler(BaseHandler):
|
||||
self.log.debug(
|
||||
"Asking for user model of %s with scopes [%s]",
|
||||
user.name,
|
||||
", ".join(self.raw_scopes),
|
||||
", ".join(self.expanded_scopes),
|
||||
)
|
||||
allowed_keys = set()
|
||||
for scope in access_map:
|
||||
|
@@ -35,20 +35,31 @@ class SelfAPIHandler(APIHandler):
|
||||
user = self.current_user
|
||||
if user is None:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
_added_scopes = set()
|
||||
if isinstance(user, orm.Service):
|
||||
# ensure we have the minimal 'identify' scopes for the token owner
|
||||
self.raw_scopes.update(scopes.identify_scopes(user))
|
||||
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
||||
model = self.service_model(user)
|
||||
identify_scopes = scopes.identify_scopes(user)
|
||||
get_model = self.service_model
|
||||
else:
|
||||
self.raw_scopes.update(scopes.identify_scopes(user.orm_user))
|
||||
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
||||
model = self.user_model(user)
|
||||
# validate return, should have at least kind and name,
|
||||
# otherwise our filters did something wrong
|
||||
for key in ("kind", "name"):
|
||||
if key not in model:
|
||||
raise ValueError(f"Missing identify model for {user}: {model}")
|
||||
identify_scopes = scopes.identify_scopes(user.orm_user)
|
||||
get_model = self.user_model
|
||||
|
||||
# ensure we have permission to identify ourselves
|
||||
# all tokens can do this on this endpoint
|
||||
for scope in identify_scopes:
|
||||
if scope not in self.expanded_scopes:
|
||||
_added_scopes.add(scope)
|
||||
self.expanded_scopes.add(scope)
|
||||
if _added_scopes:
|
||||
# re-parse with new scopes
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
|
||||
model = get_model(user)
|
||||
|
||||
# add scopes to identify model,
|
||||
# but not the scopes we added to ensure we could read our own model
|
||||
model["scopes"] = sorted(self.expanded_scopes.difference(_added_scopes))
|
||||
self.write(json.dumps(model))
|
||||
|
||||
|
||||
@@ -338,7 +349,7 @@ class UserTokenListAPIHandler(APIHandler):
|
||||
# couldn't identify requester
|
||||
raise web.HTTPError(403)
|
||||
self._jupyterhub_user = requester
|
||||
self._resolve_scopes()
|
||||
self._resolve_roles_and_scopes()
|
||||
user = self.find_user(user_name)
|
||||
kind = 'user' if isinstance(requester, User) else 'service'
|
||||
scope_filter = self.get_scope_filter('users:tokens')
|
||||
|
@@ -21,6 +21,7 @@ from datetime import timezone
|
||||
from functools import partial
|
||||
from getpass import getuser
|
||||
from glob import glob
|
||||
from itertools import chain
|
||||
from operator import itemgetter
|
||||
from textwrap import dedent
|
||||
from urllib.parse import unquote
|
||||
@@ -329,11 +330,10 @@ class JupyterHub(Application):
|
||||
load_roles = [
|
||||
{
|
||||
'name': 'teacher',
|
||||
'description': 'Access users information, servers and groups without create/delete privileges',
|
||||
'description': 'Access to users' information and group membership',
|
||||
'scopes': ['users', 'groups'],
|
||||
'users': ['cyclops', 'gandalf'],
|
||||
'services': [],
|
||||
'tokens': [],
|
||||
'groups': []
|
||||
}
|
||||
]
|
||||
@@ -394,7 +394,7 @@ class JupyterHub(Application):
|
||||
even if your Hub authentication is still valid.
|
||||
If your Hub authentication is valid,
|
||||
logging in may be a transparent redirect as you refresh the page.
|
||||
|
||||
|
||||
This does not affect JupyterHub API tokens in general,
|
||||
which do not expire by default.
|
||||
Only tokens issued during the oauth flow
|
||||
@@ -887,7 +887,7 @@ class JupyterHub(Application):
|
||||
"/",
|
||||
help="""
|
||||
The routing prefix for the Hub itself.
|
||||
|
||||
|
||||
Override to send only a subset of traffic to the Hub.
|
||||
Default is to use the Hub as the default route for all requests.
|
||||
|
||||
@@ -899,7 +899,7 @@ class JupyterHub(Application):
|
||||
may want to handle these events themselves,
|
||||
in which case they can register their own default target with the proxy
|
||||
and set e.g. `hub_routespec = /hub/` to serve only the hub's own pages, or even `/hub/api/` for api-only operation.
|
||||
|
||||
|
||||
Note: hub_routespec must include the base_url, if any.
|
||||
|
||||
.. versionadded:: 1.4
|
||||
@@ -1484,7 +1484,7 @@ class JupyterHub(Application):
|
||||
Can be a Unicode string (e.g. '/hub/home') or a callable based on the handler object:
|
||||
|
||||
::
|
||||
|
||||
|
||||
def default_url_fn(handler):
|
||||
user = handler.current_user
|
||||
if user and user.admin:
|
||||
@@ -1956,6 +1956,7 @@ class JupyterHub(Application):
|
||||
for name, usernames in self.load_groups.items():
|
||||
group = orm.Group.find(db, name)
|
||||
if group is None:
|
||||
self.log.info(f"Creating group {name}")
|
||||
group = orm.Group(name=name)
|
||||
db.add(group)
|
||||
for username in usernames:
|
||||
@@ -1970,30 +1971,77 @@ class JupyterHub(Application):
|
||||
if user is None:
|
||||
if not self.authenticator.validate_username(username):
|
||||
raise ValueError("Group username %r is not valid" % username)
|
||||
self.log.info(f"Creating user {username} for group {name}")
|
||||
user = orm.User(name=username)
|
||||
db.add(user)
|
||||
self.log.debug(f"Adding user {username} to group {name}")
|
||||
group.users.append(user)
|
||||
db.commit()
|
||||
|
||||
async def init_roles(self):
|
||||
async def init_role_creation(self):
|
||||
"""Load default and predefined roles into the database"""
|
||||
db = self.db
|
||||
# tokens are added separately
|
||||
role_bearers = ['users', 'services', 'groups']
|
||||
|
||||
# load default roles
|
||||
self.log.debug('Loading default roles to database')
|
||||
default_roles = roles.get_default_roles()
|
||||
for role in default_roles:
|
||||
roles.create_role(db, role)
|
||||
config_role_names = [r['name'] for r in self.load_roles]
|
||||
|
||||
init_roles = default_roles
|
||||
for role_name in config_role_names:
|
||||
if config_role_names.count(role_name) > 1:
|
||||
raise ValueError(
|
||||
f"Role {role_name} multiply defined. Please check the `load_roles` configuration"
|
||||
)
|
||||
for role_spec in self.load_roles:
|
||||
init_roles.append(role_spec)
|
||||
init_role_names = [r['name'] for r in init_roles]
|
||||
if not orm.Role.find(self.db, name='admin'):
|
||||
self._rbac_upgrade = True
|
||||
else:
|
||||
self._rbac_upgrade = False
|
||||
for role in self.db.query(orm.Role).filter(
|
||||
orm.Role.name.notin_(init_role_names)
|
||||
):
|
||||
app_log.info(f"Deleting role {role.name}")
|
||||
self.db.delete(role)
|
||||
self.db.commit()
|
||||
for role in init_roles:
|
||||
roles.create_role(self.db, role)
|
||||
|
||||
async def init_role_assignment(self):
|
||||
# tokens are added separately
|
||||
role_bearers = ['users', 'services', 'groups']
|
||||
admin_role_objects = ['users', 'services']
|
||||
config_admin_users = set(self.authenticator.admin_users)
|
||||
db = self.db
|
||||
# load predefined roles from config file
|
||||
if config_admin_users:
|
||||
for role_spec in self.load_roles:
|
||||
if role_spec['name'] == 'admin':
|
||||
app_log.warning(
|
||||
"Configuration specifies both admin_users and users in the admin role specification. "
|
||||
"If admin role is present in config, c.authenticator.admin_users should not be used."
|
||||
)
|
||||
app_log.info(
|
||||
"Merging admin_users set with users list in admin role"
|
||||
)
|
||||
role_spec['users'] = set(role_spec.get('users', []))
|
||||
role_spec['users'] |= config_admin_users
|
||||
self.log.debug('Loading predefined roles from config file to database')
|
||||
has_admin_role_spec = {role_bearer: False for role_bearer in admin_role_objects}
|
||||
for predef_role in self.load_roles:
|
||||
roles.create_role(db, predef_role)
|
||||
predef_role_obj = orm.Role.find(db, name=predef_role['name'])
|
||||
if predef_role['name'] == 'admin':
|
||||
for bearer in admin_role_objects:
|
||||
has_admin_role_spec[bearer] = bearer in predef_role
|
||||
if has_admin_role_spec[bearer]:
|
||||
app_log.info(f"Admin role specifies static {bearer} list")
|
||||
else:
|
||||
app_log.info(
|
||||
f"Admin role does not specify {bearer}, preserving admin membership in database"
|
||||
)
|
||||
# add users, services, and/or groups,
|
||||
# tokens need to be checked for permissions
|
||||
for bearer in role_bearers:
|
||||
orm_role_bearers = []
|
||||
if bearer in predef_role.keys():
|
||||
for bname in predef_role[bearer]:
|
||||
if bearer == 'users':
|
||||
@@ -2009,22 +2057,40 @@ class JupyterHub(Application):
|
||||
)
|
||||
Class = orm.get_class(bearer)
|
||||
orm_obj = Class.find(db, bname)
|
||||
roles.grant_role(
|
||||
db, entity=orm_obj, rolename=predef_role['name']
|
||||
)
|
||||
# make sure that on no admin situation, all roles are reset
|
||||
admin_role = orm.Role.find(db, name='admin')
|
||||
if not admin_role.users:
|
||||
if orm_obj:
|
||||
orm_role_bearers.append(orm_obj)
|
||||
else:
|
||||
app_log.warning(
|
||||
f"User {bname} not added, only found in role definition"
|
||||
)
|
||||
# Todo: Add users to allowed_users
|
||||
# Ensure all with admin role have admin flag
|
||||
if predef_role['name'] == 'admin':
|
||||
orm_obj.admin = True
|
||||
setattr(predef_role_obj, bearer, orm_role_bearers)
|
||||
db.commit()
|
||||
allowed_users = db.query(orm.User).filter(
|
||||
orm.User.name.in_(self.authenticator.allowed_users)
|
||||
)
|
||||
for user in allowed_users:
|
||||
roles.grant_role(db, user, 'user')
|
||||
admin_role = orm.Role.find(db, 'admin')
|
||||
for bearer in admin_role_objects:
|
||||
Class = orm.get_class(bearer)
|
||||
for admin_obj in db.query(Class).filter_by(admin=True):
|
||||
if has_admin_role_spec[bearer]:
|
||||
admin_obj.admin = admin_role in admin_obj.roles
|
||||
else:
|
||||
roles.grant_role(db, admin_obj, 'admin')
|
||||
db.commit()
|
||||
# make sure that on hub upgrade, all users, services and tokens have at least one role (update with default)
|
||||
if getattr(self, '_rbac_upgrade', False):
|
||||
app_log.warning(
|
||||
"No admin users found; assuming hub upgrade. Initializing default roles for all entities"
|
||||
"No admin role found; assuming hub upgrade. Initializing default roles for all entities"
|
||||
)
|
||||
# make sure all users, services and tokens have at least one role (update with default)
|
||||
for bearer in role_bearers:
|
||||
roles.check_for_default_roles(db, bearer)
|
||||
|
||||
# now add roles to tokens if their owner's permissions allow
|
||||
roles.add_predef_roles_tokens(db, self.load_roles)
|
||||
|
||||
# check tokens for default roles
|
||||
roles.check_for_default_roles(db, bearer='tokens')
|
||||
|
||||
@@ -2067,13 +2133,6 @@ class JupyterHub(Application):
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
self.log.info("Adding API token for %s: %s", kind, name)
|
||||
# If we have roles in the configuration file, they will be added later
|
||||
# Todo: works but ugly
|
||||
config_roles = None
|
||||
for config_role in self.load_roles:
|
||||
if 'tokens' in config_role and token in config_role['tokens']:
|
||||
config_roles = []
|
||||
break
|
||||
try:
|
||||
# set generated=False to ensure that user-provided tokens
|
||||
# get extra hashing (don't trust entropy of user-provided tokens)
|
||||
@@ -2081,7 +2140,6 @@ class JupyterHub(Application):
|
||||
token,
|
||||
note="from config",
|
||||
generated=self.trust_user_provided_tokens,
|
||||
roles=config_roles,
|
||||
)
|
||||
except Exception:
|
||||
if created:
|
||||
@@ -2140,6 +2198,12 @@ class JupyterHub(Application):
|
||||
# not found, create a new one
|
||||
orm_service = orm.Service(name=name)
|
||||
if spec.get('admin', False):
|
||||
self.log.warning(
|
||||
f"Service {name} sets `admin: True`, which is deprecated in JupyterHub 2.0."
|
||||
" You can assign now assign roles via `JupyterHub.load_roles` configuration."
|
||||
" If you specify services in the admin role configuration, "
|
||||
"the Service admin flag will be ignored."
|
||||
)
|
||||
roles.update_roles(self.db, entity=orm_service, roles=['admin'])
|
||||
self.db.add(orm_service)
|
||||
orm_service.admin = spec.get('admin', False)
|
||||
@@ -2203,6 +2267,10 @@ class JupyterHub(Application):
|
||||
allowed_roles=service.oauth_roles,
|
||||
description="JupyterHub service %s" % service.name,
|
||||
)
|
||||
service.orm.oauth_client_id = service.oauth_client_id
|
||||
else:
|
||||
if service.oauth_client:
|
||||
self.db.delete(service.oauth_client)
|
||||
|
||||
self._service_map[name] = service
|
||||
|
||||
@@ -2623,11 +2691,12 @@ class JupyterHub(Application):
|
||||
self.init_hub()
|
||||
self.init_proxy()
|
||||
self.init_oauth()
|
||||
await self.init_role_creation()
|
||||
await self.init_users()
|
||||
await self.init_groups()
|
||||
self.init_services()
|
||||
await self.init_api_tokens()
|
||||
await self.init_roles()
|
||||
await self.init_role_assignment()
|
||||
self.init_tornado_settings()
|
||||
self.init_handlers()
|
||||
self.init_tornado_application()
|
||||
|
@@ -112,7 +112,7 @@ class Authenticator(LoggingConfigurable):
|
||||
|
||||
Use this with supported authenticators to restrict which users can log in. This is an
|
||||
additional list that further restricts users, beyond whatever restrictions the
|
||||
authenticator has in place.
|
||||
authenticator has in place. Any user in this list is granted the 'user' role on hub startup.
|
||||
|
||||
If empty, does not perform any additional restriction.
|
||||
|
||||
|
@@ -2,6 +2,7 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
@@ -81,13 +82,13 @@ class BaseHandler(RequestHandler):
|
||||
The current user (None if not logged in) may be accessed
|
||||
via the `self.current_user` property during the handling of any request.
|
||||
"""
|
||||
self.raw_scopes = set()
|
||||
self.expanded_scopes = set()
|
||||
try:
|
||||
await self.get_current_user()
|
||||
except Exception:
|
||||
self.log.exception("Failed to get current user")
|
||||
self._jupyterhub_user = None
|
||||
self._resolve_scopes()
|
||||
self._resolve_roles_and_scopes()
|
||||
return await maybe_future(super().prepare())
|
||||
|
||||
@property
|
||||
@@ -416,17 +417,35 @@ class BaseHandler(RequestHandler):
|
||||
self.log.exception("Error getting current user")
|
||||
return self._jupyterhub_user
|
||||
|
||||
def _resolve_scopes(self):
|
||||
self.raw_scopes = set()
|
||||
def _resolve_roles_and_scopes(self):
|
||||
self.expanded_scopes = set()
|
||||
app_log.debug("Loading and parsing scopes")
|
||||
if self.current_user:
|
||||
orm_token = self.get_token()
|
||||
if orm_token:
|
||||
self.raw_scopes = scopes.get_scopes_for(orm_token)
|
||||
self.expanded_scopes = scopes.get_scopes_for(orm_token)
|
||||
else:
|
||||
self.raw_scopes = scopes.get_scopes_for(self.current_user)
|
||||
self.parsed_scopes = scopes.parse_scopes(self.raw_scopes)
|
||||
app_log.debug("Found scopes [%s]", ",".join(self.raw_scopes))
|
||||
self.expanded_scopes = scopes.get_scopes_for(self.current_user)
|
||||
self.parsed_scopes = scopes.parse_scopes(self.expanded_scopes)
|
||||
app_log.debug("Found scopes [%s]", ",".join(self.expanded_scopes))
|
||||
|
||||
@functools.lru_cache()
|
||||
def get_scope_filter(self, req_scope):
|
||||
"""Produce a filter function for req_scope on resources
|
||||
|
||||
Returns `has_access_to(orm_resource, kind)` which returns True or False
|
||||
for whether the current request has access to req_scope on the given resource.
|
||||
"""
|
||||
|
||||
def no_access(orm_resource, kind):
|
||||
return False
|
||||
|
||||
if req_scope not in self.parsed_scopes:
|
||||
return no_access
|
||||
|
||||
sub_scope = self.parsed_scopes[req_scope]
|
||||
|
||||
return functools.partial(scopes.check_scope_filter, sub_scope)
|
||||
|
||||
@property
|
||||
def current_user(self):
|
||||
|
@@ -10,7 +10,6 @@ from http.client import responses
|
||||
from jinja2 import TemplateNotFound
|
||||
from tornado import web
|
||||
from tornado.httputil import url_concat
|
||||
from tornado.httputil import urlparse
|
||||
|
||||
from .. import __version__
|
||||
from .. import orm
|
||||
@@ -590,8 +589,9 @@ class TokenPageHandler(BaseHandler):
|
||||
token = tokens[0]
|
||||
oauth_clients.append(
|
||||
{
|
||||
'client': token.client,
|
||||
'description': token.client.description or token.client.identifier,
|
||||
'client': token.oauth_client,
|
||||
'description': token.oauth_client.description
|
||||
or token.oauth_client.identifier,
|
||||
'created': created,
|
||||
'last_activity': last_activity,
|
||||
'tokens': tokens,
|
||||
|
@@ -2,8 +2,6 @@
|
||||
|
||||
implements https://oauthlib.readthedocs.io/en/latest/oauth2/server.html
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from oauthlib import uri_validate
|
||||
from oauthlib.oauth2 import RequestValidator
|
||||
from oauthlib.oauth2 import WebApplicationServer
|
||||
@@ -613,9 +611,10 @@ class JupyterHubOAuthServer(WebApplicationServer):
|
||||
allowed_roles = []
|
||||
orm_client.secret = hash_token(client_secret) if client_secret else ""
|
||||
orm_client.redirect_uri = redirect_uri
|
||||
orm_client.description = description
|
||||
orm_client.description = description or client_id
|
||||
orm_client.allowed_roles = allowed_roles
|
||||
self.db.commit()
|
||||
return orm_client
|
||||
|
||||
def fetch_by_client_id(self, client_id):
|
||||
"""Find a client by its id"""
|
||||
|
@@ -16,12 +16,12 @@ from sqlalchemy import Boolean
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy import Enum
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy import exc
|
||||
from sqlalchemy import ForeignKey
|
||||
from sqlalchemy import inspect
|
||||
from sqlalchemy import Integer
|
||||
from sqlalchemy import MetaData
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import Table
|
||||
@@ -115,7 +115,17 @@ class JSONList(JSONDict):
|
||||
return value
|
||||
|
||||
|
||||
Base = declarative_base()
|
||||
meta = MetaData(
|
||||
naming_convention={
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
)
|
||||
|
||||
Base = declarative_base(metadata=meta)
|
||||
Base.log = app_log
|
||||
|
||||
|
||||
@@ -264,7 +274,7 @@ class User(Base):
|
||||
def orm_spawners(self):
|
||||
return {s.name: s for s in self._orm_spawners}
|
||||
|
||||
admin = Column(Boolean, default=False)
|
||||
admin = Column(Boolean(create_constraint=False), default=False)
|
||||
created = Column(DateTime, default=datetime.utcnow)
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -326,6 +336,21 @@ class Spawner(Base):
|
||||
last_activity = Column(DateTime, nullable=True)
|
||||
user_options = Column(JSONDict)
|
||||
|
||||
# added in 2.0
|
||||
oauth_client_id = Column(
|
||||
Unicode(255),
|
||||
ForeignKey(
|
||||
'oauth_clients.identifier',
|
||||
ondelete='SET NULL',
|
||||
),
|
||||
)
|
||||
oauth_client = relationship(
|
||||
'OAuthClient',
|
||||
backref=backref("spawner", uselist=False),
|
||||
cascade="all, delete-orphan",
|
||||
single_parent=True,
|
||||
)
|
||||
|
||||
# properties on the spawner wrapper
|
||||
# some APIs get these low-level objects
|
||||
# when the spawner isn't running,
|
||||
@@ -361,7 +386,7 @@ class Service(Base):
|
||||
|
||||
# common user interface:
|
||||
name = Column(Unicode(255), unique=True)
|
||||
admin = Column(Boolean, default=False)
|
||||
admin = Column(Boolean(create_constraint=False), default=False)
|
||||
|
||||
api_tokens = relationship(
|
||||
"APIToken", backref="service", cascade="all, delete-orphan"
|
||||
@@ -377,6 +402,21 @@ class Service(Base):
|
||||
)
|
||||
pid = Column(Integer)
|
||||
|
||||
# added in 2.0
|
||||
oauth_client_id = Column(
|
||||
Unicode(255),
|
||||
ForeignKey(
|
||||
'oauth_clients.identifier',
|
||||
ondelete='SET NULL',
|
||||
),
|
||||
)
|
||||
oauth_client = relationship(
|
||||
'OAuthClient',
|
||||
backref=backref("service", uselist=False),
|
||||
cascade="all, delete-orphan",
|
||||
single_parent=True,
|
||||
)
|
||||
|
||||
def new_api_token(self, token=None, **kwargs):
|
||||
"""Create a new API token
|
||||
If `token` is given, load that token.
|
||||
@@ -474,9 +514,7 @@ class Hashed(Expiring):
|
||||
@classmethod
|
||||
def check_token(cls, db, token):
|
||||
"""Check if a token is acceptable"""
|
||||
print("checking", cls, token, len(token), cls.min_length)
|
||||
if len(token) < cls.min_length:
|
||||
print("raising")
|
||||
raise ValueError(
|
||||
"Tokens must be at least %i characters, got %r"
|
||||
% (cls.min_length, token)
|
||||
@@ -567,6 +605,7 @@ class APIToken(Hashed, Base):
|
||||
ondelete='CASCADE',
|
||||
),
|
||||
)
|
||||
|
||||
# FIXME: refresh_tokens not implemented
|
||||
# should be a relation to another token table
|
||||
# refresh_token = Column(
|
||||
@@ -732,6 +771,11 @@ class OAuthCode(Expiring, Base):
|
||||
.first()
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<{self.__class__.__name__}(id={self.id}, client_id={self.client_id!r})>"
|
||||
)
|
||||
|
||||
|
||||
class OAuthClient(Base):
|
||||
__tablename__ = 'oauth_clients'
|
||||
@@ -746,7 +790,7 @@ class OAuthClient(Base):
|
||||
return self.identifier
|
||||
|
||||
access_tokens = relationship(
|
||||
APIToken, backref='client', cascade='all, delete-orphan'
|
||||
APIToken, backref='oauth_client', cascade='all, delete-orphan'
|
||||
)
|
||||
codes = relationship(OAuthCode, backref='client', cascade='all, delete-orphan')
|
||||
|
||||
@@ -754,6 +798,9 @@ class OAuthClient(Base):
|
||||
# *not* the roles of the client itself
|
||||
allowed_roles = relationship('Role', secondary='oauth_client_role_map')
|
||||
|
||||
def __repr__(self):
|
||||
return f"<{self.__class__.__name__}(identifier={self.identifier!r})>"
|
||||
|
||||
|
||||
# General database utilities
|
||||
|
||||
|
@@ -8,20 +8,29 @@ from sqlalchemy import func
|
||||
from tornado.log import app_log
|
||||
|
||||
from . import orm
|
||||
from . import scopes
|
||||
|
||||
|
||||
def get_default_roles():
|
||||
"""Returns a list of default role dictionaries"""
|
||||
|
||||
"""Returns:
|
||||
default roles (list): default role definitions as dictionaries:
|
||||
{
|
||||
'name': role name,
|
||||
'description': role description,
|
||||
'scopes': list of scopes,
|
||||
}
|
||||
"""
|
||||
default_roles = [
|
||||
{
|
||||
'name': 'user',
|
||||
'description': 'Standard user privileges',
|
||||
'scopes': ['self'],
|
||||
'scopes': [
|
||||
'self',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'admin',
|
||||
'description': 'Admin privileges (currently can do everything)',
|
||||
'description': 'Admin privileges (can do everything)',
|
||||
'scopes': [
|
||||
'admin:users',
|
||||
'admin:users:servers',
|
||||
@@ -31,14 +40,17 @@ def get_default_roles():
|
||||
'read:hub',
|
||||
'proxy',
|
||||
'shutdown',
|
||||
'access:services',
|
||||
'access:users:servers',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'server',
|
||||
'description': 'Post activity only',
|
||||
'scopes': [
|
||||
'users:activity!user'
|
||||
], # TO DO - fix scope to refer to only self once implemented
|
||||
'users:activity!user',
|
||||
'access:users:servers!user',
|
||||
],
|
||||
},
|
||||
{
|
||||
'name': 'token',
|
||||
@@ -59,6 +71,13 @@ def expand_self_scope(name):
|
||||
users:activity
|
||||
users:servers
|
||||
users:tokens
|
||||
access:users:servers
|
||||
|
||||
Arguments:
|
||||
name (str): user name
|
||||
|
||||
Returns:
|
||||
expanded scopes (set): set of expanded scopes covering standard user privileges
|
||||
"""
|
||||
scope_list = [
|
||||
'users',
|
||||
@@ -70,44 +89,12 @@ def expand_self_scope(name):
|
||||
'users:roles',
|
||||
]
|
||||
read_scope_list = ['read:' + scope for scope in scope_list]
|
||||
# access doesn't want the 'read:' prefix
|
||||
scope_list.append('access:users:servers')
|
||||
scope_list.extend(read_scope_list)
|
||||
return {"{}!user={}".format(scope, name) for scope in scope_list}
|
||||
|
||||
|
||||
def _get_scope_hierarchy():
|
||||
"""
|
||||
Returns a dictionary of scopes:
|
||||
scopes.keys() = scopes of highest level and scopes that have their own subscopes
|
||||
scopes.values() = a list of first level subscopes or None
|
||||
"""
|
||||
|
||||
scopes = {
|
||||
'self': None,
|
||||
'all': None,
|
||||
'admin:users': ['admin:users:auth_state', 'users'],
|
||||
'users': ['read:users', 'users:activity'],
|
||||
'read:users': [
|
||||
'read:users:name',
|
||||
'read:users:groups',
|
||||
'read:users:activity',
|
||||
'read:users:roles',
|
||||
],
|
||||
'users:activity': ['read:users:activity'],
|
||||
'users:tokens': ['read:users:tokens'],
|
||||
'admin:users:servers': ['admin:users:server_state', 'users:servers'],
|
||||
'users:servers': ['read:users:servers'],
|
||||
'read:users:servers': ['read:users:name'],
|
||||
'admin:groups': ['groups'],
|
||||
'groups': ['read:groups'],
|
||||
'read:services': ['read:services:name', 'read:services:roles'],
|
||||
'read:hub': None,
|
||||
'proxy': None,
|
||||
'shutdown': None,
|
||||
}
|
||||
|
||||
return scopes
|
||||
|
||||
|
||||
def horizontal_filter(func):
|
||||
"""Decorator to account for horizontal filtering in scope syntax"""
|
||||
|
||||
@@ -133,67 +120,75 @@ def horizontal_filter(func):
|
||||
|
||||
@horizontal_filter
|
||||
def _expand_scope(scopename):
|
||||
"""Returns a set of all subscopes"""
|
||||
"""Returns a set of all subscopes
|
||||
Arguments:
|
||||
scopename (str): name of the scope to expand
|
||||
|
||||
scopes = _get_scope_hierarchy()
|
||||
subscopes = [scopename]
|
||||
Returns:
|
||||
expanded scope (set): set of all scope's subscopes including the scope itself
|
||||
"""
|
||||
expanded_scope = []
|
||||
|
||||
def _expand_subscopes(index):
|
||||
def _add_subscopes(scopename):
|
||||
expanded_scope.append(scopename)
|
||||
if scopes.scope_definitions[scopename].get('subscopes'):
|
||||
for subscope in scopes.scope_definitions[scopename].get('subscopes'):
|
||||
_add_subscopes(subscope)
|
||||
|
||||
more_subscopes = list(
|
||||
filter(lambda scope: scope in scopes.keys(), subscopes[index:])
|
||||
)
|
||||
for scope in more_subscopes:
|
||||
subscopes.extend(scopes[scope])
|
||||
_add_subscopes(scopename)
|
||||
|
||||
if scopename in scopes.keys() and scopes[scopename] is not None:
|
||||
subscopes.extend(scopes[scopename])
|
||||
# record the index from where it should check for "subscopes of sub-subscopes"
|
||||
index_for_sssc = len(subscopes)
|
||||
# check for "subscopes of subscopes"
|
||||
_expand_subscopes(index=1)
|
||||
# check for "subscopes of sub-subscopes"
|
||||
_expand_subscopes(index=index_for_sssc)
|
||||
|
||||
expanded_scope = set(subscopes)
|
||||
|
||||
return expanded_scope
|
||||
return set(expanded_scope)
|
||||
|
||||
|
||||
def expand_roles_to_scopes(orm_object):
|
||||
"""Get the scopes listed in the roles of the User/Service/Group/Token
|
||||
If User, take into account the user's groups roles as well"""
|
||||
If User, take into account the user's groups roles as well
|
||||
|
||||
Arguments:
|
||||
orm_object: orm.User, orm.Service, orm.Group or orm.APIToken
|
||||
|
||||
Returns:
|
||||
expanded scopes (set): set of all expanded scopes for the orm object
|
||||
"""
|
||||
if not isinstance(orm_object, orm.Base):
|
||||
raise TypeError(f"Only orm objects allowed, got {orm_object}")
|
||||
|
||||
pass_roles = orm_object.roles
|
||||
pass_roles = []
|
||||
pass_roles.extend(orm_object.roles)
|
||||
|
||||
if isinstance(orm_object, orm.User):
|
||||
groups_roles = []
|
||||
for group in orm_object.groups:
|
||||
groups_roles.extend(group.roles)
|
||||
pass_roles.extend(groups_roles)
|
||||
pass_roles.extend(group.roles)
|
||||
|
||||
scopes = _get_subscopes(*pass_roles, owner=orm_object)
|
||||
expanded_scopes = _get_subscopes(*pass_roles, owner=orm_object)
|
||||
|
||||
return scopes
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def _get_subscopes(*args, owner=None):
|
||||
"""Returns a set of all available subscopes for a specified role or list of roles"""
|
||||
"""Returns a set of all available subscopes for a specified role or list of roles
|
||||
|
||||
Arguments:
|
||||
role (obj): orm.Role
|
||||
or
|
||||
roles (list): list of orm.Roles
|
||||
owner (obj, optional): orm.User or orm.Service as owner of orm.APIToken
|
||||
|
||||
Returns:
|
||||
expanded scopes (set): set of all expanded scopes for the role(s)
|
||||
"""
|
||||
scope_list = []
|
||||
|
||||
for role in args:
|
||||
scope_list.extend(role.scopes)
|
||||
|
||||
scopes = set(chain.from_iterable(list(map(_expand_scope, scope_list))))
|
||||
expanded_scopes = set(chain.from_iterable(list(map(_expand_scope, scope_list))))
|
||||
|
||||
# transform !user filter to !user=ownername
|
||||
for scope in scopes:
|
||||
for scope in expanded_scopes:
|
||||
base_scope, _, filter = scope.partition('!')
|
||||
if filter == 'user':
|
||||
scopes.remove(scope)
|
||||
expanded_scopes.remove(scope)
|
||||
if isinstance(owner, orm.APIToken):
|
||||
token_owner = owner.user
|
||||
if token_owner is None:
|
||||
@@ -202,50 +197,59 @@ def _get_subscopes(*args, owner=None):
|
||||
else:
|
||||
name = owner.name
|
||||
trans_scope = f'{base_scope}!user={name}'
|
||||
scopes.add(trans_scope)
|
||||
expanded_scopes.add(trans_scope)
|
||||
|
||||
if 'self' in scopes:
|
||||
scopes.remove('self')
|
||||
if 'self' in expanded_scopes:
|
||||
expanded_scopes.remove('self')
|
||||
if owner and isinstance(owner, orm.User):
|
||||
scopes |= expand_self_scope(owner.name)
|
||||
expanded_scopes |= expand_self_scope(owner.name)
|
||||
|
||||
return scopes
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def _check_scopes(*args):
|
||||
"""Check if provided scopes exist"""
|
||||
def _check_scopes(*args, rolename=None):
|
||||
"""Check if provided scopes exist
|
||||
|
||||
allowed_scopes = _get_scope_hierarchy()
|
||||
Arguments:
|
||||
scope (str): name of the scope to check
|
||||
or
|
||||
scopes (list): list of scopes to check
|
||||
|
||||
Raises NameError if scope does not exist
|
||||
"""
|
||||
|
||||
allowed_scopes = set(scopes.scope_definitions.keys())
|
||||
allowed_filters = ['!user=', '!service=', '!group=', '!server=', '!user']
|
||||
subscopes = set(
|
||||
chain.from_iterable([x for x in allowed_scopes.values() if x is not None])
|
||||
)
|
||||
|
||||
if rolename:
|
||||
log_role = f"for role {rolename}"
|
||||
else:
|
||||
log_role = ""
|
||||
|
||||
for scope in args:
|
||||
# check the ! filters
|
||||
if '!' in scope:
|
||||
if any(filter in scope for filter in allowed_filters):
|
||||
scope = scope.split('!', 1)[0]
|
||||
else:
|
||||
scopename, _, filter_ = scope.partition('!')
|
||||
if scopename not in allowed_scopes:
|
||||
raise NameError(f"Scope '{scope}' {log_role} does not exist")
|
||||
if filter_:
|
||||
full_filter = f"!{filter_}"
|
||||
if not any(full_filter in scope for full_filter in allowed_filters):
|
||||
raise NameError(
|
||||
'Scope filter %r in scope %r does not exist',
|
||||
scope.split('!', 1)[1],
|
||||
scope,
|
||||
f"Scope filter '{full_filter}' in scope '{scope}' {log_role} does not exist"
|
||||
)
|
||||
# check if the actual scope syntax exists
|
||||
if scope not in allowed_scopes.keys() and scope not in subscopes:
|
||||
raise NameError('Scope %r does not exist', scope)
|
||||
|
||||
|
||||
def _overwrite_role(role, role_dict):
|
||||
"""Overwrites role's description and/or scopes with role_dict if role not 'admin'"""
|
||||
|
||||
for attr in role_dict.keys():
|
||||
if attr == 'description' or attr == 'scopes':
|
||||
if role.name == 'admin' and role_dict[attr] != getattr(role, attr):
|
||||
raise ValueError(
|
||||
'admin role description or scopes cannot be overwritten'
|
||||
)
|
||||
if role.name == 'admin':
|
||||
admin_role_spec = [
|
||||
r for r in get_default_roles() if r['name'] == 'admin'
|
||||
][0]
|
||||
if role_dict[attr] != admin_role_spec[attr]:
|
||||
raise ValueError(
|
||||
'admin role description or scopes cannot be overwritten'
|
||||
)
|
||||
else:
|
||||
if role_dict[attr] != getattr(role, attr):
|
||||
setattr(role, attr, role_dict[attr])
|
||||
@@ -276,7 +280,6 @@ def _validate_role_name(name):
|
||||
|
||||
def create_role(db, role_dict):
|
||||
"""Adds a new role to database or modifies an existing one"""
|
||||
|
||||
default_roles = get_default_roles()
|
||||
|
||||
if 'name' not in role_dict.keys():
|
||||
@@ -291,7 +294,7 @@ def create_role(db, role_dict):
|
||||
|
||||
# check if the provided scopes exist
|
||||
if scopes:
|
||||
_check_scopes(*scopes)
|
||||
_check_scopes(*scopes, rolename=role_dict['name'])
|
||||
|
||||
if role is None:
|
||||
if not scopes:
|
||||
@@ -309,7 +312,6 @@ def create_role(db, role_dict):
|
||||
|
||||
def delete_role(db, rolename):
|
||||
"""Removes a role from database"""
|
||||
|
||||
# default roles are not removable
|
||||
default_roles = get_default_roles()
|
||||
if any(role['name'] == rolename for role in default_roles):
|
||||
@@ -343,7 +345,7 @@ def existing_only(func):
|
||||
|
||||
@existing_only
|
||||
def grant_role(db, entity, rolename):
|
||||
"""Adds a role for users, services or tokens"""
|
||||
"""Adds a role for users, services, groups or tokens"""
|
||||
if isinstance(entity, orm.APIToken):
|
||||
entity_repr = entity
|
||||
else:
|
||||
@@ -362,7 +364,7 @@ def grant_role(db, entity, rolename):
|
||||
|
||||
@existing_only
|
||||
def strip_role(db, entity, rolename):
|
||||
"""Removes a role for users, services or tokens"""
|
||||
"""Removes a role for users, services, groups or tokens"""
|
||||
if isinstance(entity, orm.APIToken):
|
||||
entity_repr = entity
|
||||
else:
|
||||
@@ -397,10 +399,12 @@ def _switch_default_role(db, obj, admin):
|
||||
|
||||
|
||||
def _token_allowed_role(db, token, role):
|
||||
"""Checks if requested role for token does not grant the token
|
||||
higher permissions than the token's owner has
|
||||
|
||||
"""Returns True if token allowed to have requested role through
|
||||
comparing the requested scopes with the set of token's owner scopes"""
|
||||
|
||||
Returns:
|
||||
True if requested permissions are within the owner's permissions, False otherwise
|
||||
"""
|
||||
owner = token.user
|
||||
if owner is None:
|
||||
owner = token.service
|
||||
@@ -408,21 +412,22 @@ def _token_allowed_role(db, token, role):
|
||||
if owner is None:
|
||||
raise ValueError(f"Owner not found for {token}")
|
||||
|
||||
token_scopes = _get_subscopes(role, owner=owner)
|
||||
expanded_scopes = _get_subscopes(role, owner=owner)
|
||||
|
||||
implicit_permissions = {'all', 'read:all'}
|
||||
explicit_scopes = token_scopes - implicit_permissions
|
||||
explicit_scopes = expanded_scopes - implicit_permissions
|
||||
# ignore horizontal filters
|
||||
raw_scopes = {
|
||||
no_filter_scopes = {
|
||||
scope.split('!', 1)[0] if '!' in scope else scope for scope in explicit_scopes
|
||||
}
|
||||
# find the owner's scopes
|
||||
owner_scopes = expand_roles_to_scopes(owner)
|
||||
expanded_owner_scopes = expand_roles_to_scopes(owner)
|
||||
# ignore horizontal filters
|
||||
raw_owner_scopes = {
|
||||
scope.split('!', 1)[0] if '!' in scope else scope for scope in owner_scopes
|
||||
no_filter_owner_scopes = {
|
||||
scope.split('!', 1)[0] if '!' in scope else scope
|
||||
for scope in expanded_owner_scopes
|
||||
}
|
||||
disallowed_scopes = raw_scopes.difference(raw_owner_scopes)
|
||||
disallowed_scopes = no_filter_scopes.difference(no_filter_owner_scopes)
|
||||
if not disallowed_scopes:
|
||||
# no scopes requested outside owner's own scopes
|
||||
return True
|
||||
@@ -434,9 +439,10 @@ def _token_allowed_role(db, token, role):
|
||||
|
||||
|
||||
def assign_default_roles(db, entity):
|
||||
"""Assigns the default roles to an entity:
|
||||
"""Assigns default role to an entity:
|
||||
users and services get 'user' role, or admin role if they have admin flag
|
||||
Tokens get 'token' role"""
|
||||
tokens get 'token' role
|
||||
"""
|
||||
if isinstance(entity, orm.Group):
|
||||
pass
|
||||
elif isinstance(entity, orm.APIToken):
|
||||
@@ -448,13 +454,14 @@ def assign_default_roles(db, entity):
|
||||
db.commit()
|
||||
# users and services can have 'user' or 'admin' roles as default
|
||||
else:
|
||||
# todo: when we deprecate admin flag: replace with role check
|
||||
app_log.debug('Assigning default roles to %s', type(entity).__name__)
|
||||
_switch_default_role(db, entity, entity.admin)
|
||||
|
||||
|
||||
def update_roles(db, entity, roles):
|
||||
"""Updates object's roles"""
|
||||
"""Updates object's roles checking for requested permissions
|
||||
if object is orm.APIToken
|
||||
"""
|
||||
standard_permissions = {'all', 'read:all'}
|
||||
for rolename in roles:
|
||||
if isinstance(entity, orm.APIToken):
|
||||
@@ -477,30 +484,10 @@ def update_roles(db, entity, roles):
|
||||
grant_role(db, entity=entity, rolename=rolename)
|
||||
|
||||
|
||||
def add_predef_roles_tokens(db, predef_roles):
|
||||
|
||||
"""Adds tokens to predefined roles in config file
|
||||
if their permissions allow"""
|
||||
|
||||
for predef_role in predef_roles:
|
||||
if 'tokens' in predef_role.keys():
|
||||
token_role = orm.Role.find(db, name=predef_role['name'])
|
||||
for token_name in predef_role['tokens']:
|
||||
token = orm.APIToken.find(db, token_name)
|
||||
if token is None:
|
||||
raise ValueError(
|
||||
"Token %r does not exist and cannot assign it to role %r"
|
||||
% (token_name, token_role.name)
|
||||
)
|
||||
else:
|
||||
update_roles(db, token, roles=[token_role.name])
|
||||
|
||||
|
||||
def check_for_default_roles(db, bearer):
|
||||
|
||||
"""Checks that role bearers have at least one role (default if none).
|
||||
Groups can be without a role"""
|
||||
|
||||
Groups can be without a role
|
||||
"""
|
||||
Class = orm.get_class(bearer)
|
||||
if Class == orm.Group:
|
||||
pass
|
||||
|
@@ -1,9 +1,21 @@
|
||||
"""General scope definitions and utilities"""
|
||||
"""
|
||||
General scope definitions and utilities
|
||||
|
||||
Scope variable nomenclature
|
||||
---------------------------
|
||||
scopes: list of scopes with abbreviations (e.g., in role definition)
|
||||
expanded scopes: set of expanded scopes without abbreviations (i.e., resolved metascopes, filters and subscopes)
|
||||
parsed scopes: dictionary JSON like format of expanded scopes
|
||||
intersection : set of expanded scopes as intersection of 2 expanded scope sets
|
||||
identify scopes: set of expanded scopes needed for identify (whoami) endpoints
|
||||
"""
|
||||
import functools
|
||||
import inspect
|
||||
import warnings
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
|
||||
import sqlalchemy as sa
|
||||
from tornado import web
|
||||
from tornado.log import app_log
|
||||
|
||||
@@ -11,23 +23,131 @@ from . import orm
|
||||
from . import roles
|
||||
|
||||
|
||||
scope_definitions = {
|
||||
'(no_scope)': {'description': 'Allows for only identifying the owning entity.'},
|
||||
'self': {
|
||||
'description': 'Metascope, grants access to user’s own resources only; resolves to (no_scope) for services.'
|
||||
},
|
||||
'all': {
|
||||
'description': 'Metascope, valid for tokens only. Grants access to everything that the token’s owning entity can access.'
|
||||
},
|
||||
'admin:users': {
|
||||
'description': 'Grants read, write, create and delete access to users and their authentication state but not their servers or tokens.',
|
||||
'subscopes': ['admin:users:auth_state', 'users'],
|
||||
},
|
||||
'admin:users:auth_state': {
|
||||
'description': 'Grants access to users’ authentication state only.'
|
||||
},
|
||||
'users': {
|
||||
'description': 'Grants read and write permissions to users’ models apart from servers, tokens and authentication state.',
|
||||
'subscopes': ['read:users', 'users:activity'],
|
||||
},
|
||||
'read:users': {
|
||||
'description': 'Read-only access to users’ models apart from servers, tokens and authentication state.',
|
||||
'subscopes': [
|
||||
'read:users:name',
|
||||
'read:users:groups',
|
||||
'read:users:activity',
|
||||
],
|
||||
# TODO: add read:users:roles as subscopes here once implemented
|
||||
},
|
||||
'read:users:name': {'description': 'Read-only access to users’ names.'},
|
||||
'read:users:groups': {'description': 'Read-only access to users’ group names.'},
|
||||
'read:users:activity': {'description': 'Read-only access to users’ last activity.'},
|
||||
# TODO: add read:users:roles once implemented
|
||||
'users:activity': {
|
||||
'description': 'Grants access to read and post users’ last activity only.',
|
||||
'subscopes': ['read:users:activity'],
|
||||
},
|
||||
'admin:users:servers': {
|
||||
'description': 'Grants read, start/stop, create and delete permissions to users’ servers and their state.',
|
||||
'subscopes': ['admin:users:server_state', 'users:servers'],
|
||||
},
|
||||
'admin:users:server_state': {
|
||||
'description': 'Grants access to servers’ state only.'
|
||||
},
|
||||
'users:servers': {
|
||||
'description': 'Allows for starting/stopping users’ servers in addition to read access to their models. Does not include the server state.',
|
||||
'subscopes': ['read:users:servers'],
|
||||
},
|
||||
'read:users:servers': {
|
||||
'description': 'Read-only access to users’ names and their server models. Does not include the server state.',
|
||||
'subscopes': ['read:users:name'],
|
||||
},
|
||||
'users:tokens': {
|
||||
'description': 'Grants read, write, create and delete permissions to users’ tokens.',
|
||||
'subscopes': ['read:users:tokens'],
|
||||
},
|
||||
'read:users:tokens': {'description': 'Read-only access to users’ tokens.'},
|
||||
'admin:groups': {
|
||||
'description': 'Grants read, write, create and delete access to groups.',
|
||||
'subscopes': ['groups'],
|
||||
},
|
||||
'groups': {
|
||||
'description': 'Grants read and write permissions to groups, including adding/removing users to/from groups.',
|
||||
'subscopes': ['read:groups'],
|
||||
},
|
||||
'read:groups': {'description': 'Read-only access to groups’ models.'},
|
||||
'read:services': {
|
||||
'description': 'Read-only access to service models.',
|
||||
'subscopes': ['read:services:name'],
|
||||
# TODO: add read:services:roles as subscopes here once implemented
|
||||
},
|
||||
'read:services:name': {'description': 'Read-only access to service names.'},
|
||||
# TODO: add read:services:roles once implemented
|
||||
#'read:services:roles': {'description': 'Read-only access to service role names.'},
|
||||
'read:hub': {
|
||||
'description': 'Read-only access to detailed information about the Hub.'
|
||||
},
|
||||
'access:users:servers': {
|
||||
'description': 'Access user servers via API or browser.',
|
||||
},
|
||||
'access:services': {
|
||||
'description': 'Access services via API or browser.',
|
||||
},
|
||||
'proxy': {
|
||||
'description': 'Allows for obtaining information about the proxy’s routing table, for syncing the Hub with proxy and notifying the Hub about a new proxy.'
|
||||
},
|
||||
'shutdown': {'description': 'Grants access to shutdown the hub.'},
|
||||
}
|
||||
|
||||
|
||||
class Scope(Enum):
|
||||
ALL = True
|
||||
|
||||
|
||||
def _intersect_scopes(scopes_a, scopes_b):
|
||||
"""Intersect two sets of scopes
|
||||
def _intersect_expanded_scopes(scopes_a, scopes_b, db=None):
|
||||
"""Intersect two sets of scopes by comparing their permissions
|
||||
|
||||
Compares the permissions of two sets of scopes,
|
||||
including horizontal filters,
|
||||
and returns the intersected scopes.
|
||||
Arguments:
|
||||
scopes_a, scopes_b: sets of expanded scopes
|
||||
db (optional): db connection for resolving group membership
|
||||
|
||||
Note: Intersects correctly with ALL and exact filter matches
|
||||
(i.e. users!user=x & read:users:name -> read:users:name!user=x)
|
||||
Returns:
|
||||
intersection: set of expanded scopes as intersection of the arguments
|
||||
|
||||
Does not currently intersect with containing filters
|
||||
(i.e. users!group=x & users!user=y even if user y is in group x)
|
||||
If db is given, group membership will be accounted for in intersections,
|
||||
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()
|
||||
|
||||
# cached lookups for group membership of users and servers
|
||||
@lru_cache()
|
||||
def groups_for_user(username):
|
||||
"""Get set of group names for a given username"""
|
||||
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}
|
||||
|
||||
@lru_cache()
|
||||
def groups_for_server(server):
|
||||
"""Get set of group names for a given server"""
|
||||
username, _, servername = server.partition("/")
|
||||
return groups_for_user(username)
|
||||
|
||||
parsed_scopes_a = parse_scopes(scopes_a)
|
||||
parsed_scopes_b = parse_scopes(scopes_b)
|
||||
|
||||
@@ -43,11 +163,14 @@ def _intersect_scopes(scopes_a, scopes_b):
|
||||
elif filters_b == Scope.ALL:
|
||||
common_filters[base] = filters_a
|
||||
else:
|
||||
# warn *if* there are non-overlapping user= and group= filters
|
||||
common_entities = filters_a.keys() & filters_b.keys()
|
||||
all_entities = filters_a.keys() | filters_b.keys()
|
||||
|
||||
# if we don't have a db session, we can't check group membership
|
||||
# warn *if* there are non-overlapping user= and group= filters that we can't check
|
||||
if (
|
||||
not warned
|
||||
db is None
|
||||
and not warned
|
||||
and 'group' in all_entities
|
||||
and ('user' in all_entities or 'server' in all_entities)
|
||||
):
|
||||
@@ -59,48 +182,85 @@ def _intersect_scopes(scopes_a, scopes_b):
|
||||
not warned
|
||||
and "group" in a
|
||||
and b_key in b
|
||||
and set(a["group"]).difference(b.get("group", []))
|
||||
and set(b[b_key]).difference(a.get(b_key, []))
|
||||
and a["group"].difference(b.get("group", []))
|
||||
and b[b_key].difference(a.get(b_key, []))
|
||||
):
|
||||
warnings.warn(
|
||||
f"{base}[!{b_key}={b[b_key]}, !group={a['group']}] combinations of filters present,"
|
||||
" intersection between not considered. May result in lower than intended permissions.",
|
||||
" without db access. Intersection between not considered."
|
||||
" May result in lower than intended permissions.",
|
||||
UserWarning,
|
||||
)
|
||||
warned = True
|
||||
|
||||
common_filters[base] = {
|
||||
entity: set(parsed_scopes_a[base][entity])
|
||||
& set(parsed_scopes_b[base][entity])
|
||||
entity: filters_a[entity] & filters_b[entity]
|
||||
for entity in common_entities
|
||||
}
|
||||
|
||||
if 'server' in all_entities and 'user' in all_entities:
|
||||
if filters_a.get('server') == filters_b.get('server'):
|
||||
continue
|
||||
# resolve hierarchies (group/user/server) in both directions
|
||||
common_servers = common_filters[base].get("server", set())
|
||||
common_users = common_filters[base].get("user", set())
|
||||
|
||||
additional_servers = set()
|
||||
# resolve user/server hierarchy in both directions
|
||||
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
|
||||
if 'server' in a and 'user' in b:
|
||||
for server in a['server']:
|
||||
for a, b in [(filters_a, filters_b), (filters_b, filters_a)]:
|
||||
if 'server' in a and b.get('server') != a['server']:
|
||||
# skip already-added servers (includes overlapping servers)
|
||||
servers = a['server'].difference(common_servers)
|
||||
|
||||
# resolve user/server hierarchy
|
||||
if servers and 'user' in b:
|
||||
for server in servers:
|
||||
username, _, servername = server.partition("/")
|
||||
if username in b['user']:
|
||||
additional_servers.add(server)
|
||||
common_servers.add(server)
|
||||
|
||||
if additional_servers:
|
||||
if "server" not in common_filters[base]:
|
||||
common_filters[base]["server"] = set()
|
||||
common_filters[base]["server"].update(additional_servers)
|
||||
# resolve group/server hierarchy if db available
|
||||
servers = servers.difference(common_servers)
|
||||
if db is not None and servers and 'group' in b:
|
||||
for server in servers:
|
||||
server_groups = groups_for_server(server)
|
||||
if server_groups & b['group']:
|
||||
common_servers.add(server)
|
||||
|
||||
# resolve group/user hierarchy if db available and user sets aren't identical
|
||||
if (
|
||||
db is not None
|
||||
and 'user' in a
|
||||
and 'group' in b
|
||||
and b.get('user') != a['user']
|
||||
):
|
||||
# skip already-added users (includes overlapping users)
|
||||
users = a['user'].difference(common_users)
|
||||
for username in users:
|
||||
groups = groups_for_user(username)
|
||||
if groups & b["group"]:
|
||||
common_users.add(username)
|
||||
|
||||
# add server filter if there wasn't one before
|
||||
if common_servers and "server" not in common_filters[base]:
|
||||
common_filters[base]["server"] = common_servers
|
||||
|
||||
# add user filter if it's non-empty and there wasn't one before
|
||||
if common_users and "user" not in common_filters[base]:
|
||||
common_filters[base]["user"] = common_users
|
||||
|
||||
return unparse_scopes(common_filters)
|
||||
|
||||
|
||||
def get_scopes_for(orm_object):
|
||||
"""Find scopes for a given user or token and resolve permissions"""
|
||||
scopes = set()
|
||||
"""Find scopes for a given user or token from their roles and resolve permissions
|
||||
|
||||
Arguments:
|
||||
orm_object: orm object or User wrapper
|
||||
|
||||
Returns:
|
||||
expanded scopes (set) for the orm object
|
||||
or
|
||||
intersection (set) if orm_object == orm.APIToken
|
||||
"""
|
||||
expanded_scopes = set()
|
||||
if orm_object is None:
|
||||
return scopes
|
||||
return expanded_scopes
|
||||
|
||||
if not isinstance(orm_object, orm.Base):
|
||||
from .user import User
|
||||
@@ -116,13 +276,40 @@ def get_scopes_for(orm_object):
|
||||
app_log.warning(f"Authenticated with token {orm_object}")
|
||||
owner = orm_object.user or orm_object.service
|
||||
token_scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
if orm_object.client_id != "jupyterhub":
|
||||
# oauth tokens can be used to access the service issuing the token,
|
||||
# assuming the owner itself still has permission to do so
|
||||
spawner = orm_object.oauth_client.spawner
|
||||
if spawner:
|
||||
token_scopes.add(
|
||||
f"access:users:servers!server={spawner.user.name}/{spawner.name}"
|
||||
)
|
||||
else:
|
||||
service = orm_object.oauth_client.service
|
||||
if service:
|
||||
token_scopes.add(f"access:services!service={service.name}")
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Token {orm_object} has no associated service or spawner!"
|
||||
)
|
||||
|
||||
owner_scopes = roles.expand_roles_to_scopes(owner)
|
||||
|
||||
if token_scopes == {'all'}:
|
||||
# token_scopes is only 'all', return owner scopes as-is
|
||||
# short-circuit common case where we don't need to compute an intersection
|
||||
return owner_scopes
|
||||
|
||||
if 'all' in token_scopes:
|
||||
token_scopes.remove('all')
|
||||
token_scopes |= owner_scopes
|
||||
|
||||
scopes = _intersect_scopes(token_scopes, owner_scopes)
|
||||
discarded_token_scopes = token_scopes - scopes
|
||||
intersection = _intersect_expanded_scopes(
|
||||
token_scopes,
|
||||
owner_scopes,
|
||||
db=sa.inspect(orm_object).session,
|
||||
)
|
||||
discarded_token_scopes = token_scopes - intersection
|
||||
|
||||
# Not taking symmetric difference here because token owner can naturally have more scopes than token
|
||||
if discarded_token_scopes:
|
||||
@@ -130,9 +317,10 @@ def get_scopes_for(orm_object):
|
||||
"discarding scopes [%s], not present in owner roles"
|
||||
% ", ".join(discarded_token_scopes)
|
||||
)
|
||||
expanded_scopes = intersection
|
||||
else:
|
||||
scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
return scopes
|
||||
expanded_scopes = roles.expand_roles_to_scopes(orm_object)
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def _needs_scope_expansion(filter_, filter_value, sub_scope):
|
||||
@@ -158,7 +346,7 @@ def _check_user_in_expanded_scope(handler, user_name, scope_group_names):
|
||||
return bool(set(scope_group_names) & group_names)
|
||||
|
||||
|
||||
def _check_scope(api_handler, req_scope, **kwargs):
|
||||
def _check_scope_access(api_handler, req_scope, **kwargs):
|
||||
"""Check if scopes satisfy requirements
|
||||
Returns True for (potentially restricted) access, False for refused access
|
||||
"""
|
||||
@@ -227,25 +415,27 @@ def parse_scopes(scope_list):
|
||||
parsed_scopes[base_scope] = Scope.ALL
|
||||
elif base_scope not in parsed_scopes:
|
||||
parsed_scopes[base_scope] = {}
|
||||
|
||||
if parsed_scopes[base_scope] != Scope.ALL:
|
||||
key, _, val = filter_.partition('=')
|
||||
key, _, value = filter_.partition('=')
|
||||
if key not in parsed_scopes[base_scope]:
|
||||
parsed_scopes[base_scope][key] = []
|
||||
parsed_scopes[base_scope][key].append(val)
|
||||
parsed_scopes[base_scope][key] = set([value])
|
||||
else:
|
||||
parsed_scopes[base_scope][key].add(value)
|
||||
return parsed_scopes
|
||||
|
||||
|
||||
def unparse_scopes(parsed_scopes):
|
||||
"""Turn a parsed_scopes dictionary back into a scopes set"""
|
||||
scopes = set()
|
||||
"""Turn a parsed_scopes dictionary back into a expanded scopes set"""
|
||||
expanded_scopes = set()
|
||||
for base, filters in parsed_scopes.items():
|
||||
if filters == Scope.ALL:
|
||||
scopes.add(base)
|
||||
expanded_scopes.add(base)
|
||||
else:
|
||||
for entity, names_list in filters.items():
|
||||
for name in names_list:
|
||||
scopes.add(f'{base}!{entity}={name}')
|
||||
return scopes
|
||||
expanded_scopes.add(f'{base}!{entity}={name}')
|
||||
return expanded_scopes
|
||||
|
||||
|
||||
def needs_scope(*scopes):
|
||||
@@ -258,8 +448,8 @@ def needs_scope(*scopes):
|
||||
bound_sig = sig.bind(self, *args, **kwargs)
|
||||
bound_sig.apply_defaults()
|
||||
# Load scopes in case they haven't been loaded yet
|
||||
if not hasattr(self, 'raw_scopes'):
|
||||
self.raw_scopes = {}
|
||||
if not hasattr(self, 'expanded_scopes'):
|
||||
self.expanded_scopes = {}
|
||||
self.parsed_scopes = {}
|
||||
|
||||
s_kwargs = {}
|
||||
@@ -270,7 +460,7 @@ def needs_scope(*scopes):
|
||||
s_kwargs[resource] = resource_value
|
||||
for scope in scopes:
|
||||
app_log.debug("Checking access via scope %s", scope)
|
||||
has_access = _check_scope(self, scope, **s_kwargs)
|
||||
has_access = _check_scope_access(self, scope, **s_kwargs)
|
||||
if has_access:
|
||||
return func(self, *args, **kwargs)
|
||||
try:
|
||||
@@ -279,7 +469,7 @@ def needs_scope(*scopes):
|
||||
end_point = self.__name__
|
||||
app_log.warning(
|
||||
"Not authorizing access to {}. Requires any of [{}], not derived from scopes [{}]".format(
|
||||
end_point, ", ".join(scopes), ", ".join(self.raw_scopes)
|
||||
end_point, ", ".join(scopes), ", ".join(self.expanded_scopes)
|
||||
)
|
||||
)
|
||||
raise web.HTTPError(
|
||||
@@ -301,7 +491,7 @@ def identify_scopes(obj):
|
||||
obj: orm.User or orm.Service
|
||||
|
||||
Returns:
|
||||
scopes (set): set of scopes needed for 'identify' endpoints
|
||||
identify scopes (set): set of scopes needed for 'identify' endpoints
|
||||
"""
|
||||
if isinstance(obj, orm.User):
|
||||
return {
|
||||
@@ -317,3 +507,36 @@ def identify_scopes(obj):
|
||||
}
|
||||
else:
|
||||
raise TypeError(f"Expected orm.User or orm.Service, got {obj!r}")
|
||||
|
||||
|
||||
def check_scope_filter(sub_scope, orm_resource, kind):
|
||||
"""Return whether a sub_scope filter applies to a given resource.
|
||||
|
||||
param sub_scope: parsed_scopes filter (i.e. dict or Scope.ALL)
|
||||
param orm_resource: User or Service or Group or Spawner
|
||||
param kind: 'user' or 'service' or 'group' or 'server'.
|
||||
|
||||
Returns True or False
|
||||
"""
|
||||
if sub_scope is Scope.ALL:
|
||||
return True
|
||||
elif kind in sub_scope and orm_resource.name in sub_scope[kind]:
|
||||
return True
|
||||
|
||||
if kind == 'server':
|
||||
server_format = f"{orm_resource.user.name}/{orm_resource.name}"
|
||||
if server_format in sub_scope.get(kind, []):
|
||||
return True
|
||||
# Fall back on checking if we have user access
|
||||
if 'user' in sub_scope and orm_resource.user.name in sub_scope['user']:
|
||||
return True
|
||||
# Fall back on checking if we have group access for this user
|
||||
orm_resource = orm_resource.user
|
||||
kind = 'user'
|
||||
|
||||
if kind == 'user' and 'group' in sub_scope:
|
||||
group_names = {group.name for group in orm_resource.groups}
|
||||
user_in_group = bool(group_names & set(sub_scope['group']))
|
||||
if user_in_group:
|
||||
return True
|
||||
return False
|
||||
|
@@ -33,13 +33,50 @@ from traitlets import Dict
|
||||
from traitlets import Instance
|
||||
from traitlets import Integer
|
||||
from traitlets import observe
|
||||
from traitlets import Set
|
||||
from traitlets import Unicode
|
||||
from traitlets import validate
|
||||
from traitlets.config import SingletonConfigurable
|
||||
|
||||
from ..scopes import _intersect_expanded_scopes
|
||||
from ..utils import url_path_join
|
||||
|
||||
|
||||
def check_scopes(required_scopes, scopes):
|
||||
"""Check that required_scope(s) are in scopes
|
||||
|
||||
Returns the subset of scopes matching required_scopes,
|
||||
which is truthy if any scopes match any required scopes.
|
||||
|
||||
Correctly resolves scope filters *except* for groups -> user,
|
||||
e.g. require: access:server!user=x, have: access:server!group=y
|
||||
will not grant access to user x even if user x is in group y.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
|
||||
required_scopes: set
|
||||
The set of scopes required.
|
||||
scopes: set
|
||||
The set (or list) of scopes to check against required_scopes
|
||||
|
||||
Returns
|
||||
-------
|
||||
relevant_scopes: set
|
||||
The set of scopes in required_scopes that are present in scopes,
|
||||
which is truthy if any required scopes are present,
|
||||
and falsy otherwise.
|
||||
"""
|
||||
if isinstance(required_scopes, str):
|
||||
required_scopes = {required_scopes}
|
||||
|
||||
intersection = _intersect_expanded_scopes(required_scopes, scopes)
|
||||
# re-intersect with required_scopes in case the intersection
|
||||
# applies stricter filters than required_scopes declares
|
||||
# e.g. required_scopes = {'read:users'} and intersection has only {'read:users!user=x'}
|
||||
return set(required_scopes) & intersection
|
||||
|
||||
|
||||
class _ExpiringDict(dict):
|
||||
"""Dict-like cache for Hub API requests
|
||||
|
||||
@@ -285,6 +322,24 @@ class HubAuth(SingletonConfigurable):
|
||||
def _default_cache(self):
|
||||
return _ExpiringDict(self.cache_max_age)
|
||||
|
||||
oauth_scopes = Set(
|
||||
Unicode(),
|
||||
help="""OAuth scopes to use for allowing access.
|
||||
|
||||
Get from $JUPYTERHUB_OAUTH_SCOPES by default.
|
||||
""",
|
||||
).tag(config=True)
|
||||
|
||||
@default('oauth_scopes')
|
||||
def _default_scopes(self):
|
||||
env_scopes = os.getenv('JUPYTERHUB_OAUTH_SCOPES')
|
||||
if env_scopes:
|
||||
return set(json.loads(env_scopes))
|
||||
service_name = os.getenv("JUPYTERHUB_SERVICE_NAME")
|
||||
if service_name:
|
||||
return {f'access:services!service={service_name}'}
|
||||
return set()
|
||||
|
||||
def _check_hub_authorization(self, url, api_token, cache_key=None, use_cache=True):
|
||||
"""Identify a user with the Hub
|
||||
|
||||
@@ -495,6 +550,10 @@ class HubAuth(SingletonConfigurable):
|
||||
app_log.debug("No user identified")
|
||||
return user_model
|
||||
|
||||
def check_scopes(self, required_scopes, user):
|
||||
"""Check whether the user has required scope(s)"""
|
||||
return check_scopes(required_scopes, set(user["scopes"]))
|
||||
|
||||
|
||||
class HubOAuth(HubAuth):
|
||||
"""HubAuth using OAuth for login instead of cookies set by the Hub.
|
||||
@@ -765,12 +824,26 @@ class UserNotAllowed(Exception):
|
||||
)
|
||||
|
||||
|
||||
class HubAuthenticated(object):
|
||||
class HubAuthenticated:
|
||||
"""Mixin for tornado handlers that are authenticated with JupyterHub
|
||||
|
||||
A handler that mixes this in must have the following attributes/properties:
|
||||
|
||||
- .hub_auth: A HubAuth instance
|
||||
- .hub_scopes: A set of JupyterHub 2.0 OAuth scopes to allow.
|
||||
Default comes from .hub_auth.oauth_scopes,
|
||||
which in turn is set by $JUPYTERHUB_OAUTH_SCOPES
|
||||
Default values include:
|
||||
- 'access:services', 'access:services!service={service_name}' for services
|
||||
- 'access:users:servers', 'access:users:servers!user={user}',
|
||||
'access:users:servers!server={user}/{server_name}'
|
||||
for single-user servers
|
||||
|
||||
If hub_scopes is not used (e.g. JupyterHub 1.x),
|
||||
these additional properties can be used:
|
||||
|
||||
- .allow_admin: If True, allow any admin user.
|
||||
Default: False.
|
||||
- .hub_users: A set of usernames to allow.
|
||||
If left unspecified or None, username will not be checked.
|
||||
- .hub_groups: A set of group names to allow.
|
||||
@@ -795,13 +868,19 @@ class HubAuthenticated(object):
|
||||
hub_groups = None # set of allowed groups
|
||||
allow_admin = False # allow any admin user access
|
||||
|
||||
@property
|
||||
def hub_scopes(self):
|
||||
"""Set of allowed scopes (use hub_auth.oauth_scopes by default)"""
|
||||
return self.hub_auth.oauth_scopes or None
|
||||
|
||||
@property
|
||||
def allow_all(self):
|
||||
"""Property indicating that all successfully identified user
|
||||
or service should be allowed.
|
||||
"""
|
||||
return (
|
||||
self.hub_services is None
|
||||
self.hub_scopes is None
|
||||
and self.hub_services is None
|
||||
and self.hub_users is None
|
||||
and self.hub_groups is None
|
||||
)
|
||||
@@ -842,22 +921,43 @@ class HubAuthenticated(object):
|
||||
|
||||
Returns the input if the user should be allowed, None otherwise.
|
||||
|
||||
Override if you want to check anything other than the username's presence in hub_users list.
|
||||
Override for custom logic in authenticating users.
|
||||
|
||||
Args:
|
||||
model (dict): the user or service model returned from :class:`HubAuth`
|
||||
user_model (dict): the user or service model returned from :class:`HubAuth`
|
||||
Returns:
|
||||
user_model (dict): The user model if the user should be allowed, None otherwise.
|
||||
"""
|
||||
|
||||
name = model['name']
|
||||
kind = model.setdefault('kind', 'user')
|
||||
|
||||
if self.allow_all:
|
||||
app_log.debug(
|
||||
"Allowing Hub %s %s (all Hub users and services allowed)", kind, name
|
||||
)
|
||||
return model
|
||||
|
||||
if self.hub_scopes:
|
||||
scopes = self.hub_auth.check_scopes(self.hub_scopes, model)
|
||||
if scopes:
|
||||
app_log.debug(
|
||||
f"Allowing Hub {kind} {name} based on oauth scopes {scopes}"
|
||||
)
|
||||
return model
|
||||
else:
|
||||
app_log.warning(
|
||||
f"Not allowing Hub {kind} {name}: missing required scopes"
|
||||
)
|
||||
app_log.debug(
|
||||
f"Hub {kind} {name} needs scope(s) {self.hub_scopes}, has scope(s) {model['scopes']}"
|
||||
)
|
||||
# if hub_scopes are used, *only* hub_scopes are used
|
||||
# note: this means successful authentication, but insufficient permission
|
||||
raise UserNotAllowed(model)
|
||||
|
||||
# proceed with the pre-2.0 way if hub_scopes is not set
|
||||
|
||||
if self.allow_admin and model.get('admin', False):
|
||||
app_log.debug("Allowing Hub admin %s", name)
|
||||
return model
|
||||
|
@@ -98,6 +98,14 @@ class _ServiceSpawner(LocalProcessSpawner):
|
||||
|
||||
cwd = Unicode()
|
||||
cmd = Command(minlen=0)
|
||||
_service_name = Unicode()
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
return [
|
||||
"access:services",
|
||||
f"access:services!service={self._service_name}",
|
||||
]
|
||||
|
||||
def make_preexec_fn(self, name):
|
||||
if not name:
|
||||
@@ -330,6 +338,10 @@ class Service(LoggingConfigurable):
|
||||
"""
|
||||
return bool(self.server is not None or self.oauth_redirect_uri)
|
||||
|
||||
@property
|
||||
def oauth_client(self):
|
||||
return self.orm.oauth_client
|
||||
|
||||
@property
|
||||
def server(self):
|
||||
if self.orm.server:
|
||||
@@ -384,6 +396,7 @@ class Service(LoggingConfigurable):
|
||||
environment=env,
|
||||
api_token=self.api_token,
|
||||
oauth_client_id=self.oauth_client_id,
|
||||
_service_name=self.name,
|
||||
cookie_options=self.cookie_options,
|
||||
cwd=self.cwd,
|
||||
hub=self.hub,
|
||||
|
@@ -216,6 +216,16 @@ class Spawner(LoggingConfigurable):
|
||||
admin_access = Bool(False)
|
||||
api_token = Unicode()
|
||||
oauth_client_id = Unicode()
|
||||
|
||||
oauth_scopes = List(Unicode())
|
||||
|
||||
@default("oauth_scopes")
|
||||
def _default_oauth_scopes(self):
|
||||
return [
|
||||
f"access:users:servers!server={self.user.name}/{self.name}",
|
||||
f"access:users:servers!user={self.user.name}",
|
||||
]
|
||||
|
||||
handler = Any()
|
||||
|
||||
oauth_roles = Union(
|
||||
@@ -803,6 +813,8 @@ class Spawner(LoggingConfigurable):
|
||||
self.user.url, self.name, 'oauth_callback'
|
||||
)
|
||||
|
||||
env['JUPYTERHUB_OAUTH_SCOPES'] = json.dumps(self.oauth_scopes)
|
||||
|
||||
# Info previously passed on args
|
||||
env['JUPYTERHUB_USER'] = self.user.name
|
||||
env['JUPYTERHUB_SERVER_NAME'] = self.name
|
||||
|
@@ -27,7 +27,6 @@ Fixtures to add functionality or spawning behavior
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import asyncio
|
||||
import inspect
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from getpass import getuser
|
||||
@@ -50,7 +49,6 @@ from ..utils import random_port
|
||||
from .mocking import MockHub
|
||||
from .test_services import mockservice_cmd
|
||||
from .utils import add_user
|
||||
from .utils import ssl_setup
|
||||
|
||||
# global db session object
|
||||
_db = None
|
||||
@@ -339,3 +337,79 @@ def slow_bad_spawn(app):
|
||||
app.tornado_settings, {'spawner_class': mocking.SlowBadSpawner}
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@fixture
|
||||
def create_temp_role(app):
|
||||
"""Generate a temporary role with certain scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_roles = []
|
||||
index = [1]
|
||||
|
||||
def temp_role_creator(scopes, role_name=None):
|
||||
if not role_name:
|
||||
role_name = f'temp_role_{index[0]}'
|
||||
index[0] += 1
|
||||
temp_role = orm.Role(name=role_name, scopes=list(scopes))
|
||||
temp_roles.append(temp_role)
|
||||
app.db.add(temp_role)
|
||||
app.db.commit()
|
||||
return temp_role
|
||||
|
||||
yield temp_role_creator
|
||||
for role in temp_roles:
|
||||
app.db.delete(role)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
@fixture
|
||||
def create_user_with_scopes(app, create_temp_role):
|
||||
"""Generate a temporary user with specific scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_users = []
|
||||
counter = 0
|
||||
get_role = create_temp_role
|
||||
|
||||
def temp_user_creator(*scopes, name=None):
|
||||
nonlocal counter
|
||||
if name is None:
|
||||
counter += 1
|
||||
name = f"temp_user_{counter}"
|
||||
role = get_role(scopes)
|
||||
orm_user = orm.User(name=name)
|
||||
app.db.add(orm_user)
|
||||
app.db.commit()
|
||||
temp_users.append(orm_user)
|
||||
update_roles(app.db, orm_user, roles=[role.name])
|
||||
return app.users[orm_user.id]
|
||||
|
||||
yield temp_user_creator
|
||||
for user in temp_users:
|
||||
app.users.delete(user)
|
||||
|
||||
|
||||
@fixture
|
||||
def create_service_with_scopes(app, create_temp_role):
|
||||
"""Generate a temporary service with specific scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_service = []
|
||||
counter = 0
|
||||
role_function = create_temp_role
|
||||
|
||||
def temp_service_creator(*scopes, name=None):
|
||||
nonlocal counter
|
||||
if name is None:
|
||||
counter += 1
|
||||
name = f"temp_service_{counter}"
|
||||
role = role_function(scopes)
|
||||
app.services.append({'name': name})
|
||||
app.init_services()
|
||||
orm_service = orm.Service.find(app.db, name)
|
||||
app.db.commit()
|
||||
update_roles(app.db, orm_service, roles=[role.name])
|
||||
return orm_service
|
||||
|
||||
yield temp_service_creator
|
||||
for service in temp_service:
|
||||
app.db.delete(service)
|
||||
app.db.commit()
|
||||
|
@@ -388,6 +388,10 @@ class MockSingleUserServer(SingleUserNotebookApp):
|
||||
def init_signal(self):
|
||||
pass
|
||||
|
||||
@default("log_level")
|
||||
def _default_log_level(self):
|
||||
return 10
|
||||
|
||||
|
||||
class StubSingleUserSpawner(MockSpawner):
|
||||
"""Spawner that starts a MockSingleUserServer in a thread."""
|
||||
@@ -425,6 +429,7 @@ class StubSingleUserSpawner(MockSpawner):
|
||||
app.initialize(args)
|
||||
assert app.hub_auth.oauth_client_id
|
||||
assert app.hub_auth.api_token
|
||||
assert app.hub_auth.oauth_scopes
|
||||
app.start()
|
||||
|
||||
self._thread = threading.Thread(target=_run)
|
||||
|
@@ -21,6 +21,7 @@ from urllib.parse import urlparse
|
||||
import requests
|
||||
from tornado import httpserver
|
||||
from tornado import ioloop
|
||||
from tornado import log
|
||||
from tornado import web
|
||||
|
||||
from jupyterhub.services.auth import HubAuthenticated
|
||||
@@ -114,7 +115,9 @@ def main():
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from tornado.options import parse_command_line
|
||||
from tornado.options import parse_command_line, options
|
||||
|
||||
parse_command_line()
|
||||
options.logging = 'debug'
|
||||
log.enable_pretty_logging()
|
||||
main()
|
||||
|
@@ -307,7 +307,7 @@ async def test_get_self(app):
|
||||
db.commit()
|
||||
oauth_token = orm.APIToken(
|
||||
user=u.orm_user,
|
||||
client=oauth_client,
|
||||
oauth_client=oauth_client,
|
||||
token=token,
|
||||
)
|
||||
db.add(oauth_token)
|
||||
|
@@ -364,7 +364,7 @@ def test_user_delete_cascade(db):
|
||||
oauth_code = orm.OAuthCode(client=oauth_client, user=user)
|
||||
db.add(oauth_code)
|
||||
oauth_token = orm.APIToken(
|
||||
client=oauth_client,
|
||||
oauth_client=oauth_client,
|
||||
user=user,
|
||||
)
|
||||
db.add(oauth_token)
|
||||
@@ -401,7 +401,7 @@ def test_oauth_client_delete_cascade(db):
|
||||
oauth_code = orm.OAuthCode(client=oauth_client, user=user)
|
||||
db.add(oauth_code)
|
||||
oauth_token = orm.APIToken(
|
||||
client=oauth_client,
|
||||
oauth_client=oauth_client,
|
||||
user=user,
|
||||
)
|
||||
db.add(oauth_token)
|
||||
@@ -525,7 +525,7 @@ def test_expiring_oauth_token(app, user):
|
||||
db.add(client)
|
||||
orm_token = orm.APIToken(
|
||||
token=token,
|
||||
client=client,
|
||||
oauth_client=client,
|
||||
user=user,
|
||||
expires_at=now() + timedelta(seconds=30),
|
||||
)
|
||||
|
@@ -870,7 +870,7 @@ async def test_oauth_token_page(app):
|
||||
client = orm.OAuthClient(identifier='token')
|
||||
app.db.add(client)
|
||||
oauth_token = orm.APIToken(
|
||||
client=client,
|
||||
oauth_client=client,
|
||||
user=user,
|
||||
)
|
||||
app.db.add(oauth_token)
|
||||
|
@@ -2,19 +2,19 @@
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
import json
|
||||
import os
|
||||
from itertools import chain
|
||||
|
||||
import pytest
|
||||
from pytest import mark
|
||||
from tornado.log import app_log
|
||||
from traitlets.config import Config
|
||||
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..scopes import get_scopes_for
|
||||
from ..utils import maybe_future
|
||||
from ..utils import utcnow
|
||||
from .mocking import MockHub
|
||||
from .test_scopes import create_temp_role
|
||||
from .utils import add_user
|
||||
from .utils import api_request
|
||||
|
||||
@@ -215,6 +215,16 @@ def test_orm_roles_delete_cascade(db):
|
||||
),
|
||||
(['read:users:servers'], {'read:users:servers', 'read:users:name'}),
|
||||
(['admin:groups'], {'admin:groups', 'groups', 'read:groups'}),
|
||||
(
|
||||
['admin:groups', 'read:users:servers'],
|
||||
{
|
||||
'admin:groups',
|
||||
'groups',
|
||||
'read:groups',
|
||||
'read:users:servers',
|
||||
'read:users:name',
|
||||
},
|
||||
),
|
||||
(
|
||||
['users:tokens!group=hobbits'],
|
||||
{'users:tokens!group=hobbits', 'read:users:tokens!group=hobbits'},
|
||||
@@ -240,7 +250,7 @@ async def test_load_default_roles(tmpdir, request):
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
await hub.init_roles()
|
||||
await hub.init_role_creation()
|
||||
# test default roles loaded to database
|
||||
default_roles = roles.get_default_roles()
|
||||
for role in default_roles:
|
||||
@@ -437,9 +447,9 @@ async def test_load_roles_users(tmpdir, request):
|
||||
db = hub.db
|
||||
hub.authenticator.admin_users = ['admin']
|
||||
hub.authenticator.allowed_users = ['cyclops', 'gandalf', 'bilbo', 'gargamel']
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_roles()
|
||||
|
||||
await hub.init_role_assignment()
|
||||
admin_role = orm.Role.find(db, 'admin')
|
||||
user_role = orm.Role.find(db, 'user')
|
||||
# test if every user has a role (and no duplicates)
|
||||
@@ -449,7 +459,7 @@ async def test_load_roles_users(tmpdir, request):
|
||||
assert len(user.roles) == len(set(user.roles))
|
||||
if user.admin:
|
||||
assert admin_role in user.roles
|
||||
assert user_role not in user.roles
|
||||
assert user_role in user.roles
|
||||
|
||||
# test if predefined roles loaded and assigned
|
||||
teacher_role = orm.Role.find(db, name='teacher')
|
||||
@@ -500,13 +510,13 @@ async def test_load_roles_services(tmpdir, request):
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
await hub.init_role_creation()
|
||||
await hub.init_api_tokens()
|
||||
# make 'admin_service' admin
|
||||
admin_service = orm.Service.find(db, 'admin_service')
|
||||
admin_service.admin = True
|
||||
db.commit()
|
||||
await hub.init_roles()
|
||||
|
||||
await hub.init_role_assignment()
|
||||
# test if every service has a role (and no duplicates)
|
||||
admin_role = orm.Role.find(db, name='admin')
|
||||
user_role = orm.Role.find(db, name='user')
|
||||
@@ -573,8 +583,9 @@ async def test_load_roles_groups(tmpdir, request):
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
await hub.init_role_creation()
|
||||
await hub.init_groups()
|
||||
await hub.init_roles()
|
||||
await hub.init_role_assignment()
|
||||
|
||||
assist_role = orm.Role.find(db, name='assistant')
|
||||
head_role = orm.Role.find(db, name='head')
|
||||
@@ -605,7 +616,6 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
||||
'name': 'reader',
|
||||
'description': 'Read all users models',
|
||||
'scopes': ['read:users'],
|
||||
'tokens': ['super-secret-token'],
|
||||
},
|
||||
]
|
||||
kwargs = {
|
||||
@@ -620,15 +630,10 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
||||
db = hub.db
|
||||
hub.authenticator.admin_users = ['admin']
|
||||
hub.authenticator.allowed_users = ['cyclops', 'gandalf']
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_api_tokens()
|
||||
await hub.init_roles()
|
||||
|
||||
# test if gandalf's token has the 'reader' role
|
||||
reader_role = orm.Role.find(db, 'reader')
|
||||
token = orm.APIToken.find(db, 'super-secret-token')
|
||||
assert reader_role in token.roles
|
||||
|
||||
await hub.init_role_assignment()
|
||||
# test if all other tokens have default 'user' role
|
||||
token_role = orm.Role.find(db, 'token')
|
||||
secret_token = orm.APIToken.find(db, 'secret-token')
|
||||
@@ -646,163 +651,6 @@ async def test_load_roles_user_tokens(tmpdir, request):
|
||||
roles.delete_role(db, role['name'])
|
||||
|
||||
|
||||
@mark.role
|
||||
async def test_load_roles_user_tokens_not_allowed(tmpdir, request):
|
||||
user_tokens = {
|
||||
'secret-token': 'bilbo',
|
||||
}
|
||||
roles_to_load = [
|
||||
{
|
||||
'name': 'user-creator',
|
||||
'description': 'Creates/deletes any user',
|
||||
'scopes': ['admin:users'],
|
||||
'tokens': ['secret-token'],
|
||||
},
|
||||
]
|
||||
kwargs = {
|
||||
'load_roles': roles_to_load,
|
||||
'api_tokens': user_tokens,
|
||||
}
|
||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||
if ssl_enabled:
|
||||
kwargs['internal_certs_location'] = str(tmpdir)
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
hub.authenticator.allowed_users = ['bilbo']
|
||||
await hub.init_users()
|
||||
await hub.init_api_tokens()
|
||||
|
||||
response = 'allowed'
|
||||
# bilbo has only default 'user' role
|
||||
# while bilbo's token is requesting role with higher permissions
|
||||
with pytest.raises(ValueError):
|
||||
await hub.init_roles()
|
||||
|
||||
# delete the test tokens
|
||||
for token in db.query(orm.APIToken):
|
||||
db.delete(token)
|
||||
db.commit()
|
||||
|
||||
# delete the test roles
|
||||
for role in roles_to_load:
|
||||
roles.delete_role(db, role['name'])
|
||||
|
||||
|
||||
@mark.role
|
||||
async def test_load_roles_service_tokens(tmpdir, request):
|
||||
services = [
|
||||
{'name': 'idle-culler', 'api_token': 'another-secret-token'},
|
||||
]
|
||||
service_tokens = {
|
||||
'another-secret-token': 'idle-culler',
|
||||
}
|
||||
roles_to_load = [
|
||||
{
|
||||
'name': 'idle-culler',
|
||||
'description': 'Cull idle servers',
|
||||
'scopes': [
|
||||
'read:users:name',
|
||||
'read:users:activity',
|
||||
'read:users:servers',
|
||||
'users:servers',
|
||||
],
|
||||
'services': ['idle-culler'],
|
||||
'tokens': ['another-secret-token'],
|
||||
},
|
||||
]
|
||||
kwargs = {
|
||||
'load_roles': roles_to_load,
|
||||
'services': services,
|
||||
'service_tokens': service_tokens,
|
||||
}
|
||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||
if ssl_enabled:
|
||||
kwargs['internal_certs_location'] = str(tmpdir)
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
await hub.init_api_tokens()
|
||||
await hub.init_roles()
|
||||
|
||||
# test if another-secret-token has idle-culler role
|
||||
service = orm.Service.find(db, 'idle-culler')
|
||||
culler_role = orm.Role.find(db, 'idle-culler')
|
||||
token = orm.APIToken.find(db, 'another-secret-token')
|
||||
assert len(token.roles) == 1
|
||||
assert culler_role in token.roles
|
||||
|
||||
# delete the test services
|
||||
for service in db.query(orm.Service):
|
||||
db.delete(service)
|
||||
db.commit()
|
||||
|
||||
# delete the test tokens
|
||||
for token in db.query(orm.APIToken):
|
||||
db.delete(token)
|
||||
db.commit()
|
||||
|
||||
# delete the test roles
|
||||
for role in roles_to_load:
|
||||
roles.delete_role(db, role['name'])
|
||||
|
||||
|
||||
@mark.role
|
||||
async def test_load_roles_service_tokens_not_allowed(tmpdir, request):
|
||||
services = [{'name': 'some-service', 'api_token': 'secret-token'}]
|
||||
service_tokens = {
|
||||
'secret-token': 'some-service',
|
||||
}
|
||||
roles_to_load = [
|
||||
{
|
||||
'name': 'user-reader',
|
||||
'description': 'Read-only user models',
|
||||
'scopes': ['read:users'],
|
||||
'services': ['some-service'],
|
||||
},
|
||||
# 'idle-culler' role has higher permissions that the token's owner 'some-service'
|
||||
{
|
||||
'name': 'idle-culler',
|
||||
'description': 'Cull idle servers',
|
||||
'scopes': [
|
||||
'read:users:name',
|
||||
'read:users:activity',
|
||||
'read:users:servers',
|
||||
'users:servers',
|
||||
],
|
||||
'tokens': ['secret-token'],
|
||||
},
|
||||
]
|
||||
kwargs = {
|
||||
'load_roles': roles_to_load,
|
||||
'services': services,
|
||||
'service_tokens': service_tokens,
|
||||
}
|
||||
ssl_enabled = getattr(request.module, "ssl_enabled", False)
|
||||
if ssl_enabled:
|
||||
kwargs['internal_certs_location'] = str(tmpdir)
|
||||
hub = MockHub(**kwargs)
|
||||
hub.init_db()
|
||||
db = hub.db
|
||||
await hub.init_api_tokens()
|
||||
with pytest.raises(ValueError):
|
||||
await hub.init_roles()
|
||||
|
||||
# delete the test services
|
||||
for service in db.query(orm.Service):
|
||||
db.delete(service)
|
||||
db.commit()
|
||||
|
||||
# delete the test tokens
|
||||
for token in db.query(orm.APIToken):
|
||||
db.delete(token)
|
||||
db.commit()
|
||||
|
||||
# delete the test roles
|
||||
for role in roles_to_load:
|
||||
roles.delete_role(db, role['name'])
|
||||
|
||||
|
||||
@mark.role
|
||||
@mark.parametrize(
|
||||
"headers, rolename, scopes, status",
|
||||
@@ -1049,3 +897,383 @@ async def test_oauth_allowed_roles(app, create_temp_role):
|
||||
app_service = app.services[0]
|
||||
assert app_service['name'] == 'oas1'
|
||||
assert set(app_service['oauth_roles']) == set(allowed_roles)
|
||||
|
||||
|
||||
async def test_user_group_roles(app, create_temp_role):
|
||||
user = add_user(app.db, app, name='jack')
|
||||
another_user = add_user(app.db, app, name='jill')
|
||||
|
||||
group = orm.Group.find(app.db, name='A')
|
||||
if not group:
|
||||
group = orm.Group(name='A')
|
||||
app.db.add(group)
|
||||
app.db.commit()
|
||||
|
||||
if group not in user.groups:
|
||||
user.groups.append(group)
|
||||
app.db.commit()
|
||||
|
||||
if group not in another_user.groups:
|
||||
another_user.groups.append(group)
|
||||
app.db.commit()
|
||||
|
||||
group_role = orm.Role.find(app.db, 'student-a')
|
||||
if not group_role:
|
||||
create_temp_role(['read:groups!group=A'], 'student-a')
|
||||
roles.grant_role(app.db, group, rolename='student-a')
|
||||
group_role = orm.Role.find(app.db, 'student-a')
|
||||
|
||||
# repeat check to ensure group roles don't get added to the user at all
|
||||
# regression test for #3472
|
||||
roles_before = list(user.roles)
|
||||
for i in range(3):
|
||||
roles.expand_roles_to_scopes(user.orm_user)
|
||||
user_roles = list(user.roles)
|
||||
assert user_roles == roles_before
|
||||
|
||||
# jack's API token
|
||||
token = user.new_api_token()
|
||||
|
||||
headers = {'Authorization': 'token %s' % token}
|
||||
r = await api_request(app, 'users', method='get', headers=headers)
|
||||
assert r.status_code == 200
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
|
||||
print(reply)
|
||||
|
||||
assert len(reply[0]['roles']) == 1
|
||||
assert reply[0]['name'] == 'jack'
|
||||
assert group_role.name not in reply[0]['roles']
|
||||
|
||||
headers = {'Authorization': 'token %s' % token}
|
||||
r = await api_request(app, 'groups', method='get', headers=headers)
|
||||
assert r.status_code == 200
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
|
||||
print(reply)
|
||||
|
||||
headers = {'Authorization': 'token %s' % token}
|
||||
r = await api_request(app, 'users', method='get', headers=headers)
|
||||
assert r.status_code == 200
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
|
||||
print(reply)
|
||||
|
||||
assert len(reply[0]['roles']) == 1
|
||||
assert reply[0]['name'] == 'jack'
|
||||
assert group_role.name not in reply[0]['roles']
|
||||
|
||||
|
||||
async def test_config_role_list():
|
||||
roles_to_load = [
|
||||
{
|
||||
'name': 'elephant',
|
||||
'description': 'pacing about',
|
||||
'scopes': ['read:hub'],
|
||||
},
|
||||
{
|
||||
'name': 'tiger',
|
||||
'description': 'pouncing stuff',
|
||||
'scopes': ['shutdown'],
|
||||
},
|
||||
]
|
||||
hub = MockHub(load_roles=roles_to_load)
|
||||
hub.init_db()
|
||||
hub.authenticator.admin_users = ['admin']
|
||||
await hub.init_role_creation()
|
||||
for role_conf in roles_to_load:
|
||||
assert orm.Role.find(hub.db, name=role_conf['name'])
|
||||
# Now remove elephant from config and see if it is removed from database
|
||||
roles_to_load.pop(0)
|
||||
hub.load_roles = roles_to_load
|
||||
await hub.init_role_creation()
|
||||
assert orm.Role.find(hub.db, name='tiger')
|
||||
assert not orm.Role.find(hub.db, name='elephant')
|
||||
|
||||
|
||||
async def test_config_role_users():
|
||||
role_name = 'painter'
|
||||
user_name = 'benny'
|
||||
user_names = ['agnetha', 'bjorn', 'anni-frid', user_name]
|
||||
roles_to_load = [
|
||||
{
|
||||
'name': role_name,
|
||||
'description': 'painting with colors',
|
||||
'scopes': ['users', 'groups'],
|
||||
'users': user_names,
|
||||
},
|
||||
]
|
||||
hub = MockHub(load_roles=roles_to_load)
|
||||
hub.init_db()
|
||||
hub.authenticator.admin_users = ['admin']
|
||||
hub.authenticator.allowed_users = user_names
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user = orm.User.find(hub.db, name=user_name)
|
||||
role = orm.Role.find(hub.db, name=role_name)
|
||||
assert role in user.roles
|
||||
# Now reload and see if user is removed from role list
|
||||
roles_to_load[0]['users'].remove(user_name)
|
||||
hub.load_roles = roles_to_load
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user = orm.User.find(hub.db, name=user_name)
|
||||
role = orm.Role.find(hub.db, name=role_name)
|
||||
assert role not in user.roles
|
||||
|
||||
|
||||
async def test_duplicate_role_users():
|
||||
role_name = 'painter'
|
||||
user_name = 'benny'
|
||||
user_names = ['agnetha', 'bjorn', 'anni-frid', user_name]
|
||||
roles_to_load = [
|
||||
{
|
||||
'name': role_name,
|
||||
'description': 'painting with colors',
|
||||
'scopes': ['users', 'groups'],
|
||||
'users': user_names,
|
||||
},
|
||||
{
|
||||
'name': role_name,
|
||||
'description': 'painting with colors',
|
||||
'scopes': ['users', 'groups'],
|
||||
'users': user_names,
|
||||
},
|
||||
]
|
||||
hub = MockHub(load_roles=roles_to_load)
|
||||
hub.init_db()
|
||||
with pytest.raises(ValueError):
|
||||
await hub.init_role_creation()
|
||||
|
||||
|
||||
async def test_admin_role_and_flag():
|
||||
admin_role_spec = [
|
||||
{
|
||||
'name': 'admin',
|
||||
'users': ['eddy'],
|
||||
}
|
||||
]
|
||||
hub = MockHub(load_roles=admin_role_spec)
|
||||
hub.init_db()
|
||||
hub.authenticator.admin_users = ['admin']
|
||||
hub.authenticator.allowed_users = ['eddy']
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
admin_role = orm.Role.find(hub.db, name='admin')
|
||||
for user_name in ['eddy', 'admin']:
|
||||
user = orm.User.find(hub.db, name=user_name)
|
||||
assert user.admin
|
||||
assert admin_role in user.roles
|
||||
admin_role_spec[0]['users'].remove('eddy')
|
||||
hub.load_roles = admin_role_spec
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user = orm.User.find(hub.db, name='eddy')
|
||||
assert not user.admin
|
||||
assert admin_role not in user.roles
|
||||
|
||||
|
||||
async def test_custom_role_reset():
|
||||
user_role_spec = [
|
||||
{
|
||||
'name': 'user',
|
||||
'scopes': ['self', 'shutdown'],
|
||||
'users': ['eddy'],
|
||||
}
|
||||
]
|
||||
hub = MockHub(load_roles=user_role_spec)
|
||||
hub.init_db()
|
||||
hub.authenticator.allowed_users = ['eddy']
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user_role = orm.Role.find(hub.db, name='user')
|
||||
user = orm.User.find(hub.db, name='eddy')
|
||||
assert user_role in user.roles
|
||||
assert 'shutdown' in user_role.scopes
|
||||
hub.load_roles = []
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user_role = orm.Role.find(hub.db, name='user')
|
||||
user = orm.User.find(hub.db, name='eddy')
|
||||
assert user_role in user.roles
|
||||
assert 'shutdown' not in user_role.scopes
|
||||
|
||||
|
||||
async def test_removal_config_to_db():
|
||||
role_spec = [
|
||||
{
|
||||
'name': 'user',
|
||||
'scopes': ['self', 'shutdown'],
|
||||
},
|
||||
{
|
||||
'name': 'wizard',
|
||||
'scopes': ['self', 'read:groups'],
|
||||
},
|
||||
]
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
hub.init_db()
|
||||
await hub.init_role_creation()
|
||||
assert orm.Role.find(hub.db, 'user')
|
||||
assert orm.Role.find(hub.db, 'wizard')
|
||||
hub.load_roles = []
|
||||
await hub.init_role_creation()
|
||||
assert orm.Role.find(hub.db, 'user')
|
||||
assert not orm.Role.find(hub.db, 'wizard')
|
||||
|
||||
|
||||
async def test_no_admin_role_change():
|
||||
role_spec = [{'name': 'admin', 'scopes': ['shutdown']}]
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
hub.init_db()
|
||||
with pytest.raises(ValueError):
|
||||
await hub.init_role_creation()
|
||||
|
||||
|
||||
async def test_user_config_respects_memberships():
|
||||
role_spec = [
|
||||
{
|
||||
'name': 'user',
|
||||
'scopes': ['self', 'shutdown'],
|
||||
}
|
||||
]
|
||||
user_names = ['eddy', 'carol']
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
hub.init_db()
|
||||
hub.authenticator.allowed_users = user_names
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user_role = orm.Role.find(hub.db, 'user')
|
||||
for user_name in user_names:
|
||||
user = orm.User.find(hub.db, user_name)
|
||||
assert user in user_role.users
|
||||
|
||||
|
||||
async def test_admin_role_respects_config():
|
||||
role_spec = [
|
||||
{
|
||||
'name': 'admin',
|
||||
}
|
||||
]
|
||||
admin_users = ['eddy', 'carol']
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
hub.init_db()
|
||||
hub.authenticator.admin_users = admin_users
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
admin_role = orm.Role.find(hub.db, 'admin')
|
||||
for user_name in admin_users:
|
||||
user = orm.User.find(hub.db, user_name)
|
||||
assert user in admin_role.users
|
||||
|
||||
|
||||
async def test_empty_admin_spec():
|
||||
role_spec = [{'name': 'admin', 'users': []}]
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
hub.init_db()
|
||||
hub.authenticator.admin_users = []
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
admin_role = orm.Role.find(hub.db, 'admin')
|
||||
assert not admin_role.users
|
||||
|
||||
|
||||
# Todo: Test that services don't get default roles on any startup
|
||||
|
||||
|
||||
async def test_hub_upgrade_detection(tmpdir):
|
||||
db_url = f"sqlite:///{tmpdir.join('jupyterhub.sqlite')}"
|
||||
os.environ['JUPYTERHUB_TEST_DB_URL'] = db_url
|
||||
# Create hub with users and tokens
|
||||
hub = MockHub(db_url=db_url)
|
||||
await hub.initialize()
|
||||
user_names = ['patricia', 'quentin']
|
||||
user_role = orm.Role.find(hub.db, 'user')
|
||||
for name in user_names:
|
||||
user = add_user(hub.db, name=name)
|
||||
user.new_api_token()
|
||||
assert user_role in user.roles
|
||||
for role in hub.db.query(orm.Role):
|
||||
hub.db.delete(role)
|
||||
hub.db.commit()
|
||||
# Restart hub in emulated upgrade mode: default roles for all entities
|
||||
hub.test_clean_db = False
|
||||
await hub.initialize()
|
||||
assert getattr(hub, '_rbac_upgrade', False)
|
||||
user_role = orm.Role.find(hub.db, 'user')
|
||||
token_role = orm.Role.find(hub.db, 'token')
|
||||
for name in user_names:
|
||||
user = orm.User.find(hub.db, name)
|
||||
assert user_role in user.roles
|
||||
assert token_role in user.api_tokens[0].roles
|
||||
# Strip all roles and see if it sticks
|
||||
user_role.users = []
|
||||
token_role.tokens = []
|
||||
hub.db.commit()
|
||||
|
||||
hub.init_db()
|
||||
hub.init_hub()
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
hub.authenticator.allowed_users = ['patricia']
|
||||
await hub.init_api_tokens()
|
||||
await hub.init_role_assignment()
|
||||
assert not getattr(hub, '_rbac_upgrade', False)
|
||||
user_role = orm.Role.find(hub.db, 'user')
|
||||
token_role = orm.Role.find(hub.db, 'token')
|
||||
allowed_user = orm.User.find(hub.db, 'patricia')
|
||||
rem_user = orm.User.find(hub.db, 'quentin')
|
||||
assert user_role in allowed_user.roles
|
||||
assert token_role not in allowed_user.api_tokens[0].roles
|
||||
assert user_role not in rem_user.roles
|
||||
assert token_role not in rem_user.roles
|
||||
|
||||
|
||||
async def test_token_keep_roles_on_restart():
|
||||
role_spec = [
|
||||
{
|
||||
'name': 'bloop',
|
||||
'scopes': ['read:users'],
|
||||
}
|
||||
]
|
||||
|
||||
hub = MockHub(load_roles=role_spec)
|
||||
hub.init_db()
|
||||
hub.authenticator.admin_users = ['ben']
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_role_assignment()
|
||||
user = orm.User.find(hub.db, name='ben')
|
||||
for _ in range(3):
|
||||
user.new_api_token()
|
||||
happy_token, content_token, sad_token = user.api_tokens
|
||||
roles.grant_role(hub.db, happy_token, 'bloop')
|
||||
roles.strip_role(hub.db, sad_token, 'token')
|
||||
assert len(happy_token.roles) == 2
|
||||
assert len(content_token.roles) == 1
|
||||
assert len(sad_token.roles) == 0
|
||||
# Restart hub and see if roles are as expected
|
||||
hub.load_roles = []
|
||||
await hub.init_role_creation()
|
||||
await hub.init_users()
|
||||
await hub.init_api_tokens()
|
||||
await hub.init_role_assignment()
|
||||
user = orm.User.find(hub.db, name='ben')
|
||||
happy_token, content_token, sad_token = user.api_tokens
|
||||
assert len(happy_token.roles) == 1
|
||||
assert len(content_token.roles) == 1
|
||||
print(sad_token.roles)
|
||||
assert len(sad_token.roles) == 0
|
||||
for token in user.api_tokens:
|
||||
hub.db.delete(token)
|
||||
hub.db.commit()
|
||||
|
@@ -9,8 +9,8 @@ from tornado.httputil import HTTPServerRequest
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..handlers import BaseHandler
|
||||
from ..scopes import _check_scope
|
||||
from ..scopes import _intersect_scopes
|
||||
from ..scopes import _check_scope_access
|
||||
from ..scopes import _intersect_expanded_scopes
|
||||
from ..scopes import get_scopes_for
|
||||
from ..scopes import needs_scope
|
||||
from ..scopes import parse_scopes
|
||||
@@ -49,49 +49,51 @@ def test_scope_precendence():
|
||||
|
||||
def test_scope_check_present():
|
||||
handler = get_handler_with_scopes(['read:users'])
|
||||
assert _check_scope(handler, 'read:users')
|
||||
assert _check_scope(handler, 'read:users', user='maeby')
|
||||
assert _check_scope_access(handler, 'read:users')
|
||||
assert _check_scope_access(handler, 'read:users', user='maeby')
|
||||
|
||||
|
||||
def test_scope_check_not_present():
|
||||
handler = get_handler_with_scopes(['read:users!user=maeby'])
|
||||
assert _check_scope(handler, 'read:users')
|
||||
assert _check_scope_access(handler, 'read:users')
|
||||
with pytest.raises(web.HTTPError):
|
||||
_check_scope(handler, 'read:users', user='gob')
|
||||
_check_scope_access(handler, 'read:users', user='gob')
|
||||
with pytest.raises(web.HTTPError):
|
||||
_check_scope(handler, 'read:users', user='gob', server='server')
|
||||
_check_scope_access(handler, 'read:users', user='gob', server='server')
|
||||
|
||||
|
||||
def test_scope_filters():
|
||||
handler = get_handler_with_scopes(
|
||||
['read:users', 'read:users!group=bluths', 'read:users!user=maeby']
|
||||
)
|
||||
assert _check_scope(handler, 'read:users', group='bluth')
|
||||
assert _check_scope(handler, '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(handler, 'read:users', user='george_michael', group='bluths')
|
||||
assert _check_scope_access(
|
||||
handler, 'read:users', user='george_michael', group='bluths'
|
||||
)
|
||||
|
||||
|
||||
def test_scope_parse_server_name():
|
||||
handler = get_handler_with_scopes(
|
||||
['users:servers!server=maeby/server1', 'read:users!user=maeby']
|
||||
)
|
||||
assert _check_scope(handler, 'users:servers', user='maeby', server='server1')
|
||||
assert _check_scope_access(handler, 'users:servers', user='maeby', server='server1')
|
||||
|
||||
|
||||
class MockAPIHandler:
|
||||
def __init__(self):
|
||||
self.raw_scopes = {'users'}
|
||||
self.expanded_scopes = {'users'}
|
||||
self.parsed_scopes = {}
|
||||
self.request = mock.Mock(spec=HTTPServerRequest)
|
||||
self.request.path = '/path'
|
||||
|
||||
def set_scopes(self, *scopes):
|
||||
self.raw_scopes = set(scopes)
|
||||
self.parsed_scopes = parse_scopes(self.raw_scopes)
|
||||
self.expanded_scopes = set(scopes)
|
||||
self.parsed_scopes = parse_scopes(self.expanded_scopes)
|
||||
|
||||
@needs_scope('users')
|
||||
def user_thing(self, user_name):
|
||||
@@ -193,7 +195,7 @@ def test_scope_method_access(mock_handler, scopes, method, arguments, is_allowed
|
||||
def test_double_scoped_method_succeeds(mock_handler):
|
||||
mock_handler.current_user = mock.Mock(name='lucille')
|
||||
mock_handler.set_scopes('users', 'read:services')
|
||||
mock_handler.parsed_scopes = parse_scopes(mock_handler.raw_scopes)
|
||||
mock_handler.parsed_scopes = parse_scopes(mock_handler.expanded_scopes)
|
||||
assert mock_handler.secret_thing()
|
||||
|
||||
|
||||
@@ -258,82 +260,6 @@ async def test_by_fake_user(app):
|
||||
err_message = "No access to resources or resources not found"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_temp_role(app):
|
||||
"""Generate a temporary role with certain scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_roles = []
|
||||
index = [1]
|
||||
|
||||
def temp_role_creator(scopes, role_name=None):
|
||||
if not role_name:
|
||||
role_name = f'temp_role_{index[0]}'
|
||||
index[0] += 1
|
||||
temp_role = orm.Role(name=role_name, scopes=list(scopes))
|
||||
temp_roles.append(temp_role)
|
||||
app.db.add(temp_role)
|
||||
app.db.commit()
|
||||
return temp_role
|
||||
|
||||
yield temp_role_creator
|
||||
for role in temp_roles:
|
||||
app.db.delete(role)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_user_with_scopes(app, create_temp_role):
|
||||
"""Generate a temporary user with specific scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_users = []
|
||||
counter = 0
|
||||
get_role = create_temp_role
|
||||
|
||||
def temp_user_creator(*scopes, name=None):
|
||||
nonlocal counter
|
||||
if name is None:
|
||||
counter += 1
|
||||
name = f"temp_user_{counter}"
|
||||
role = get_role(scopes)
|
||||
orm_user = orm.User(name=name)
|
||||
app.db.add(orm_user)
|
||||
app.db.commit()
|
||||
temp_users.append(orm_user)
|
||||
roles.update_roles(app.db, orm_user, roles=[role.name])
|
||||
return app.users[orm_user.id]
|
||||
|
||||
yield temp_user_creator
|
||||
for user in temp_users:
|
||||
app.users.delete(user)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def create_service_with_scopes(app, create_temp_role):
|
||||
"""Generate a temporary service with specific scopes.
|
||||
Convenience function that provides setup, database handling and teardown"""
|
||||
temp_service = []
|
||||
counter = 0
|
||||
role_function = create_temp_role
|
||||
|
||||
def temp_service_creator(*scopes, name=None):
|
||||
nonlocal counter
|
||||
if name is None:
|
||||
counter += 1
|
||||
name = f"temp_service_{counter}"
|
||||
role = role_function(scopes)
|
||||
app.services.append({'name': name})
|
||||
app.init_services()
|
||||
orm_service = orm.Service.find(app.db, name)
|
||||
app.db.commit()
|
||||
roles.update_roles(app.db, orm_service, roles=[role.name])
|
||||
return orm_service
|
||||
|
||||
yield temp_service_creator
|
||||
for service in temp_service:
|
||||
app.db.delete(service)
|
||||
app.db.commit()
|
||||
|
||||
|
||||
async def test_request_fake_user(app, create_user_with_scopes):
|
||||
fake_user = 'annyong'
|
||||
user = create_user_with_scopes('read:users!group=stuff')
|
||||
@@ -619,7 +545,6 @@ async def test_server_state_access(
|
||||
)
|
||||
service = create_service_with_scopes(*scopes)
|
||||
api_token = service.new_api_token()
|
||||
await app.init_roles()
|
||||
headers = {'Authorization': 'token %s' % api_token}
|
||||
r = await api_request(app, 'users', user.name, headers=headers)
|
||||
r.raise_for_status()
|
||||
@@ -874,13 +799,91 @@ async def test_roles_access(app, create_service_with_scopes, create_user_with_sc
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_intersect_scopes(left, right, expected, should_warn, recwarn):
|
||||
def test_intersect_expanded_scopes(left, right, expected, should_warn, recwarn):
|
||||
# run every test in both directions, to ensure symmetry of the inputs
|
||||
for a, b in [(left, right), (right, left)]:
|
||||
intersection = _intersect_scopes(set(left), set(right))
|
||||
intersection = _intersect_expanded_scopes(set(left), set(right))
|
||||
assert intersection == set(expected)
|
||||
|
||||
if should_warn:
|
||||
assert len(recwarn) == 1
|
||||
else:
|
||||
assert len(recwarn) == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"left, right, expected, groups",
|
||||
[
|
||||
(
|
||||
["users!group=gx"],
|
||||
["users!user=ux"],
|
||||
["users!user=ux"],
|
||||
{"gx": ["ux"]},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!user=nosuchuser"],
|
||||
[],
|
||||
{},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!server=nosuchuser/server"],
|
||||
[],
|
||||
{},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!server=ux/server"],
|
||||
["read:users!server=ux/server"],
|
||||
{"gx": ["ux"]},
|
||||
),
|
||||
(
|
||||
["read:users!group=gx"],
|
||||
["read:users!server=ux/server", "read:users!user=uy"],
|
||||
["read:users!server=ux/server"],
|
||||
{"gx": ["ux"], "gy": ["uy"]},
|
||||
),
|
||||
(
|
||||
["read:users!group=gy"],
|
||||
["read:users!server=ux/server", "read:users!user=uy"],
|
||||
["read:users!user=uy"],
|
||||
{"gx": ["ux"], "gy": ["uy"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_intersect_groups(request, db, left, right, expected, groups):
|
||||
if isinstance(left, str):
|
||||
left = set([left])
|
||||
if isinstance(right, str):
|
||||
right = set([right])
|
||||
|
||||
# if we have a db connection, we can actually resolve
|
||||
created = []
|
||||
for groupname, members in groups.items():
|
||||
group = orm.Group.find(db, name=groupname)
|
||||
if not group:
|
||||
group = orm.Group(name=groupname)
|
||||
db.add(group)
|
||||
created.append(group)
|
||||
db.commit()
|
||||
for username in members:
|
||||
user = orm.User.find(db, name=username)
|
||||
if user is None:
|
||||
user = orm.User(name=username)
|
||||
db.add(user)
|
||||
created.append(user)
|
||||
user.groups.append(group)
|
||||
db.commit()
|
||||
|
||||
def _cleanup():
|
||||
for obj in created:
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
|
||||
request.addfinalizer(_cleanup)
|
||||
|
||||
# run every test in both directions, to ensure symmetry of the inputs
|
||||
for a, b in [(left, right), (right, left)]:
|
||||
intersection = _intersect_expanded_scopes(set(left), set(right), db)
|
||||
assert intersection == set(expected)
|
||||
|
@@ -89,10 +89,10 @@ async def test_external_service(app):
|
||||
}
|
||||
]
|
||||
await maybe_future(app.init_services())
|
||||
await maybe_future(app.init_roles())
|
||||
await maybe_future(app.init_role_creation())
|
||||
await app.init_api_tokens()
|
||||
await app.proxy.add_all_services(app._service_map)
|
||||
await app.init_roles()
|
||||
await app.init_role_assignment()
|
||||
|
||||
service = app._service_map[name]
|
||||
api_token = service.orm.api_tokens[0]
|
||||
|
@@ -1,35 +1,20 @@
|
||||
"""Tests for service authentication"""
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from binascii import hexlify
|
||||
from functools import partial
|
||||
from queue import Queue
|
||||
from threading import Thread
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import requests_mock
|
||||
from pytest import raises
|
||||
from tornado.httpserver import HTTPServer
|
||||
from tornado.httputil import url_concat
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.web import Application
|
||||
from tornado.web import authenticated
|
||||
from tornado.web import HTTPError
|
||||
from tornado.web import RequestHandler
|
||||
|
||||
from .. import orm
|
||||
from .. import roles
|
||||
from ..services.auth import _ExpiringDict
|
||||
from ..services.auth import HubOAuth
|
||||
from ..services.auth import HubOAuthenticated
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_host
|
||||
from .mocking import public_url
|
||||
from .test_api import add_user
|
||||
from .utils import async_requests
|
||||
@@ -76,20 +61,29 @@ def test_expiring_dict():
|
||||
assert cache.get('key', 'default') == 'cached value'
|
||||
|
||||
|
||||
async def test_hubauth_token(app, mockservice_url):
|
||||
async def test_hubauth_token(app, mockservice_url, create_user_with_scopes):
|
||||
"""Test HubAuthenticated service with user API tokens"""
|
||||
u = add_user(app.db, name='river')
|
||||
u = create_user_with_scopes("access:services")
|
||||
token = u.new_api_token()
|
||||
no_access_token = u.new_api_token(roles=[])
|
||||
app.db.commit()
|
||||
|
||||
# token without sufficient permission in Authorization header
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
headers={'Authorization': f'token {no_access_token}'},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
# token in Authorization header
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
headers={'Authorization': 'token %s' % token},
|
||||
headers={'Authorization': f'token {token}'},
|
||||
)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||
assert sub_reply == {'name': 'river', 'admin': False}
|
||||
assert sub_reply == {'name': u.name, 'admin': 'missing'}
|
||||
|
||||
# token in ?token parameter
|
||||
r = await async_requests.get(
|
||||
@@ -98,7 +92,7 @@ async def test_hubauth_token(app, mockservice_url):
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ['name', 'admin']}
|
||||
assert sub_reply == {'name': 'river', 'admin': False}
|
||||
assert sub_reply == {'name': u.name, 'admin': 'missing'}
|
||||
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
|
||||
@@ -111,37 +105,96 @@ async def test_hubauth_token(app, mockservice_url):
|
||||
assert path.endswith('/hub/login')
|
||||
|
||||
|
||||
async def test_hubauth_service_token(app, mockservice_url):
|
||||
@pytest.mark.parametrize(
|
||||
"scopes, allowed",
|
||||
[
|
||||
(
|
||||
[
|
||||
"access:services",
|
||||
],
|
||||
True,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:services!service=$service",
|
||||
],
|
||||
True,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:services!service=other-service",
|
||||
],
|
||||
False,
|
||||
),
|
||||
(
|
||||
[
|
||||
"access:users:servers!user=$service",
|
||||
],
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_hubauth_service_token(request, app, mockservice_url, scopes, allowed):
|
||||
"""Test HubAuthenticated service with service API tokens"""
|
||||
|
||||
scopes = [scope.replace('$service', mockservice_url.name) for scope in scopes]
|
||||
|
||||
token = hexlify(os.urandom(5)).decode('utf8')
|
||||
name = 'test-api-service'
|
||||
app.service_tokens[token] = name
|
||||
await app.init_api_tokens()
|
||||
|
||||
orm_service = app.db.query(orm.Service).filter_by(name=name).one()
|
||||
role_name = "test-hubauth-service-token"
|
||||
|
||||
roles.create_role(
|
||||
app.db,
|
||||
{
|
||||
"name": role_name,
|
||||
"description": "role for test",
|
||||
"scopes": scopes,
|
||||
},
|
||||
)
|
||||
request.addfinalizer(lambda: roles.delete_role(app.db, role_name))
|
||||
roles.grant_role(app.db, orm_service, role_name)
|
||||
|
||||
# token in Authorization header
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/',
|
||||
public_url(app, mockservice_url) + 'whoami/',
|
||||
headers={'Authorization': 'token %s' % token},
|
||||
allow_redirects=False,
|
||||
)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
service_model = {'kind': 'service', 'name': name, 'admin': False, 'roles': []}
|
||||
assert service_model.items() <= reply.items()
|
||||
assert not r.cookies
|
||||
service_model = {
|
||||
'kind': 'service',
|
||||
'name': name,
|
||||
'admin': False,
|
||||
'roles': [role_name],
|
||||
'scopes': scopes,
|
||||
}
|
||||
if allowed:
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert service_model.items() <= reply.items()
|
||||
assert not r.cookies
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
|
||||
# token in ?token parameter
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=%s' % token
|
||||
public_url(app, mockservice_url) + 'whoami/?token=%s' % token
|
||||
)
|
||||
r.raise_for_status()
|
||||
reply = r.json()
|
||||
assert service_model.items() <= reply.items()
|
||||
if allowed:
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
assert service_model.items() <= reply.items()
|
||||
assert not r.cookies
|
||||
else:
|
||||
assert r.status_code == 403
|
||||
|
||||
r = await async_requests.get(
|
||||
public_url(app, mockservice_url) + '/whoami/?token=no-such-token',
|
||||
public_url(app, mockservice_url) + 'whoami/?token=no-such-token',
|
||||
allow_redirects=False,
|
||||
)
|
||||
assert r.status_code == 302
|
||||
@@ -171,14 +224,15 @@ async def test_hubauth_service_token(app, mockservice_url):
|
||||
(["token", "user"], ["identify"], []),
|
||||
# any item outside the list isn't allowed
|
||||
(["token", "user"], ["token", "server"], None),
|
||||
# reuesting subset
|
||||
# requesting subset
|
||||
(["admin", "user"], ["user"], ["user"]),
|
||||
(["user", "token", "server"], ["token", "user"], ["token", "user"]),
|
||||
],
|
||||
)
|
||||
async def test_oauth_service(
|
||||
async def test_oauth_service_roles(
|
||||
app,
|
||||
mockservice_url,
|
||||
create_user_with_scopes,
|
||||
client_allowed_roles,
|
||||
request_roles,
|
||||
expected_roles,
|
||||
@@ -196,7 +250,9 @@ async def test_oauth_service(
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to login and get us to the oauth form page
|
||||
s = AsyncSession()
|
||||
name = 'link'
|
||||
user = create_user_with_scopes("access:services")
|
||||
roles.grant_role(app.db, user, "user")
|
||||
name = user.name
|
||||
s.cookies = await app.login_user(name)
|
||||
|
||||
r = await s.get(url)
|
||||
@@ -232,7 +288,7 @@ async def test_oauth_service(
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ('kind', 'name')}
|
||||
assert sub_reply == {'name': 'link', 'kind': 'user'}
|
||||
assert sub_reply == {'name': user.name, 'kind': 'user'}
|
||||
|
||||
# token-authenticated request to HubOAuth
|
||||
token = app.users[name].new_api_token()
|
||||
@@ -252,12 +308,78 @@ async def test_oauth_service(
|
||||
assert reply['name'] == name
|
||||
|
||||
|
||||
async def test_oauth_cookie_collision(app, mockservice_url):
|
||||
@pytest.mark.parametrize(
|
||||
"access_scopes, expect_success",
|
||||
[
|
||||
(["access:services"], True),
|
||||
(["access:services!service=$service"], True),
|
||||
(["access:services!service=other-service"], False),
|
||||
(["self"], False),
|
||||
([], False),
|
||||
],
|
||||
)
|
||||
async def test_oauth_access_scopes(
|
||||
app,
|
||||
mockservice_url,
|
||||
create_user_with_scopes,
|
||||
access_scopes,
|
||||
expect_success,
|
||||
):
|
||||
"""Check that oauth/authorize validates access scopes"""
|
||||
service = mockservice_url
|
||||
access_scopes = [s.replace("$service", service.name) for s in access_scopes]
|
||||
url = url_path_join(public_url(app, mockservice_url) + 'owhoami/?arg=x')
|
||||
# first request is only going to login and get us to the oauth form page
|
||||
s = AsyncSession()
|
||||
user = create_user_with_scopes(*access_scopes)
|
||||
name = user.name
|
||||
s.cookies = await app.login_user(name)
|
||||
|
||||
r = await s.get(url)
|
||||
if not expect_success:
|
||||
assert r.status_code == 403
|
||||
return
|
||||
r.raise_for_status()
|
||||
# we should be looking at the oauth confirmation page
|
||||
assert urlparse(r.url).path == app.base_url + 'hub/api/oauth2/authorize'
|
||||
# verify oauth state cookie was set at some point
|
||||
assert set(r.history[0].cookies.keys()) == {'service-%s-oauth-state' % service.name}
|
||||
|
||||
# submit the oauth form to complete authorization
|
||||
r = await s.post(r.url, headers={'Referer': r.url})
|
||||
r.raise_for_status()
|
||||
assert r.url == url
|
||||
# verify oauth cookie is set
|
||||
assert 'service-%s' % service.name in set(s.cookies.keys())
|
||||
# verify oauth state cookie has been consumed
|
||||
assert 'service-%s-oauth-state' % service.name not in set(s.cookies.keys())
|
||||
|
||||
# second request should be authenticated, which means no redirects
|
||||
r = await s.get(url, allow_redirects=False)
|
||||
r.raise_for_status()
|
||||
assert r.status_code == 200
|
||||
reply = r.json()
|
||||
sub_reply = {key: reply.get(key, 'missing') for key in ('kind', 'name')}
|
||||
assert sub_reply == {'name': name, 'kind': 'user'}
|
||||
|
||||
# revoke user access, should result in 403
|
||||
user.roles = []
|
||||
app.db.commit()
|
||||
|
||||
# reset session id to avoid cached response
|
||||
s.cookies.pop('jupyterhub-session-id')
|
||||
|
||||
r = await s.get(url, allow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
async def test_oauth_cookie_collision(app, mockservice_url, create_user_with_scopes):
|
||||
service = mockservice_url
|
||||
url = url_path_join(public_url(app, mockservice_url), 'owhoami/')
|
||||
print(url)
|
||||
s = AsyncSession()
|
||||
name = 'mypha'
|
||||
user = create_user_with_scopes("access:services", name=name)
|
||||
s.cookies = await app.login_user(name)
|
||||
state_cookie_name = 'service-%s-oauth-state' % service.name
|
||||
service_cookie_name = 'service-%s' % service.name
|
||||
@@ -310,7 +432,7 @@ async def test_oauth_cookie_collision(app, mockservice_url):
|
||||
assert state_cookies == []
|
||||
|
||||
|
||||
async def test_oauth_logout(app, mockservice_url):
|
||||
async def test_oauth_logout(app, mockservice_url, create_user_with_scopes):
|
||||
"""Verify that logout via the Hub triggers logout for oauth services
|
||||
|
||||
1. clears session id cookie
|
||||
@@ -324,11 +446,11 @@ async def test_oauth_logout(app, mockservice_url):
|
||||
# first request is only going to set login cookie
|
||||
s = AsyncSession()
|
||||
name = 'propha'
|
||||
app_user = add_user(app.db, app=app, name=name)
|
||||
user = create_user_with_scopes("access:services", name=name)
|
||||
|
||||
def auth_tokens():
|
||||
"""Return list of OAuth access tokens for the user"""
|
||||
return list(app.db.query(orm.APIToken).filter_by(user_id=app_user.id))
|
||||
return list(app.db.query(orm.APIToken).filter_by(user_id=user.id))
|
||||
|
||||
# ensure we start empty
|
||||
assert auth_tokens() == []
|
||||
|
@@ -3,7 +3,10 @@ import sys
|
||||
from subprocess import check_output
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
import jupyterhub
|
||||
from .. import orm
|
||||
from ..utils import url_path_join
|
||||
from .mocking import public_url
|
||||
from .mocking import StubSingleUserSpawner
|
||||
@@ -11,7 +14,33 @@ from .utils import async_requests
|
||||
from .utils import AsyncSession
|
||||
|
||||
|
||||
async def test_singleuser_auth(app):
|
||||
@pytest.mark.parametrize(
|
||||
"access_scopes, server_name, expect_success",
|
||||
[
|
||||
(["access:users:servers!group=$group"], "", True),
|
||||
(["access:users:servers!group=other-group"], "", False),
|
||||
(["access:users:servers"], "", True),
|
||||
(["access:users:servers"], "named", True),
|
||||
(["access:users:servers!user=$user"], "", True),
|
||||
(["access:users:servers!user=$user"], "named", True),
|
||||
(["access:users:servers!server=$server"], "", True),
|
||||
(["access:users:servers!server=$server"], "named-server", True),
|
||||
(["access:users:servers!server=$user/other"], "", False),
|
||||
(["access:users:servers!server=$user/other"], "some-name", False),
|
||||
(["access:users:servers!user=$other"], "", False),
|
||||
(["access:users:servers!user=$other"], "named", False),
|
||||
(["access:services"], "", False),
|
||||
(["self"], "named", False),
|
||||
([], "", False),
|
||||
],
|
||||
)
|
||||
async def test_singleuser_auth(
|
||||
app,
|
||||
create_user_with_scopes,
|
||||
access_scopes,
|
||||
server_name,
|
||||
expect_success,
|
||||
):
|
||||
# use StubSingleUserSpawner to launch a single-user app in a thread
|
||||
app.spawner_class = StubSingleUserSpawner
|
||||
app.tornado_settings['spawner_class'] = StubSingleUserSpawner
|
||||
@@ -19,10 +48,21 @@ async def test_singleuser_auth(app):
|
||||
# login, start the server
|
||||
cookies = await app.login_user('nandy')
|
||||
user = app.users['nandy']
|
||||
if not user.running:
|
||||
await user.spawn()
|
||||
await app.proxy.add_user(user)
|
||||
url = public_url(app, user)
|
||||
|
||||
group = orm.Group.find(app.db, name="visitors")
|
||||
if group is None:
|
||||
group = orm.Group(name="visitors")
|
||||
app.db.add(group)
|
||||
app.db.commit()
|
||||
if group not in user.groups:
|
||||
user.groups.append(group)
|
||||
app.db.commit()
|
||||
|
||||
if server_name not in user.spawners or not user.spawners[server_name].active:
|
||||
await user.spawn(server_name)
|
||||
await app.proxy.add_user(user, server_name)
|
||||
spawner = user.spawners[server_name]
|
||||
url = url_path_join(public_url(app, user), server_name)
|
||||
|
||||
# no cookies, redirects to login page
|
||||
r = await async_requests.get(url)
|
||||
@@ -40,7 +80,11 @@ async def test_singleuser_auth(app):
|
||||
assert (
|
||||
urlparse(r.url)
|
||||
.path.rstrip('/')
|
||||
.endswith(url_path_join('/user/nandy', user.spawner.default_url or "/tree"))
|
||||
.endswith(
|
||||
url_path_join(
|
||||
f'/user/{user.name}', spawner.name, spawner.default_url or "/tree"
|
||||
)
|
||||
)
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
@@ -49,17 +93,40 @@ async def test_singleuser_auth(app):
|
||||
assert len(r.cookies) == 0
|
||||
|
||||
# accessing another user's server hits the oauth confirmation page
|
||||
access_scopes = [s.replace("$user", user.name) for s in access_scopes]
|
||||
access_scopes = [
|
||||
s.replace("$server", f"{user.name}/{server_name}") for s in access_scopes
|
||||
]
|
||||
access_scopes = [s.replace("$group", f"{group.name}") for s in access_scopes]
|
||||
other_user = create_user_with_scopes(*access_scopes, name="burgess")
|
||||
|
||||
cookies = await app.login_user('burgess')
|
||||
s = AsyncSession()
|
||||
s.cookies = cookies
|
||||
r = await s.get(url)
|
||||
assert urlparse(r.url).path.endswith('/oauth2/authorize')
|
||||
if not expect_success:
|
||||
# user isn't authorized, should raise 403
|
||||
assert r.status_code == 403
|
||||
return
|
||||
r.raise_for_status()
|
||||
# submit the oauth form to complete authorization
|
||||
r = await s.post(r.url, data={'scopes': ['identify']}, headers={'Referer': r.url})
|
||||
final_url = urlparse(r.url).path.rstrip('/')
|
||||
final_path = url_path_join('/user/nandy', user.spawner.default_url or "/tree")
|
||||
final_path = url_path_join(
|
||||
'/user/', user.name, spawner.name, spawner.default_url or "/tree"
|
||||
)
|
||||
assert final_url.endswith(final_path)
|
||||
# user isn't authorized, should raise 403
|
||||
r.raise_for_status()
|
||||
|
||||
# revoke user access, should result in 403
|
||||
other_user.roles = []
|
||||
app.db.commit()
|
||||
|
||||
# reset session id to avoid cached response
|
||||
s.cookies.pop('jupyterhub-session-id')
|
||||
|
||||
r = await s.get(r.url, allow_redirects=False)
|
||||
assert r.status_code == 403
|
||||
assert 'burgess' in r.text
|
||||
|
||||
|
@@ -590,15 +590,11 @@ class User:
|
||||
client_id = spawner.oauth_client_id
|
||||
oauth_provider = self.settings.get('oauth_provider')
|
||||
if oauth_provider:
|
||||
oauth_client = oauth_provider.fetch_by_client_id(client_id)
|
||||
# create a new OAuth client + secret on every launch
|
||||
# containers that resume will be updated below
|
||||
|
||||
allowed_roles = spawner.oauth_roles
|
||||
if callable(allowed_roles):
|
||||
allowed_roles = allowed_roles(spawner)
|
||||
|
||||
oauth_provider.add_client(
|
||||
oauth_client = oauth_provider.add_client(
|
||||
client_id,
|
||||
api_token,
|
||||
url_path_join(self.url, server_name, 'oauth_callback'),
|
||||
@@ -606,6 +602,7 @@ class User:
|
||||
description="Server at %s"
|
||||
% (url_path_join(self.base_url, server_name) + '/'),
|
||||
)
|
||||
spawner.orm_spawner.oauth_client = oauth_client
|
||||
db.commit()
|
||||
|
||||
# trigger pre-spawn hook on authenticator
|
||||
@@ -614,7 +611,7 @@ class User:
|
||||
spawner._start_pending = True
|
||||
|
||||
if authenticator:
|
||||
# pre_spawn_start can thow errors that can lead to a redirect loop
|
||||
# pre_spawn_start can throw errors that can lead to a redirect loop
|
||||
# if left uncaught (see https://github.com/jupyterhub/jupyterhub/issues/2683)
|
||||
await maybe_future(authenticator.pre_spawn_start(self, spawner))
|
||||
|
||||
|
Reference in New Issue
Block a user