Skip to content

Commit

Permalink
Merge branch 'nebari-dev:main' into 255-diagram-docs
Browse files Browse the repository at this point in the history
  • Loading branch information
viniciusdc committed Jun 18, 2024
2 parents d957065 + 6e6ad75 commit 9681cf9
Show file tree
Hide file tree
Showing 82 changed files with 3,107 additions and 1,795 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/build-ui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ jobs:
working-directory: ui

- name: Build and Copy
run: npm run buildCopy
run: npm run build
working-directory: ui

- name: Get Last Commit
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/test-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ jobs:
- ubuntu-latest
jupyterhub:
- "4.1.5"
- "5.0.0b1"
- "5.0.0"
steps:
- uses: actions/checkout@v4.1.1

Expand Down Expand Up @@ -88,7 +88,7 @@ jobs:
- name: Run Tests
run: |
pytest jhub_apps/tests_e2e/ -vvv -m "${{ matrix.test_type }}"
pytest jhub_apps/tests/tests_e2e/ -vvv -m "${{ matrix.test_type }}"
- name: Create artifact name
id: artifact-name
Expand Down
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ jobs:
- "3.11"
- "3.12"
test_type:
- tests
- tests_unit
jupyterhub:
- ">=4.1"
- ">=5.0.0b1"
- "==4.1.5"
- ">=5.0.0"
steps:
- uses: actions/checkout@v4.1.1

Expand Down Expand Up @@ -51,4 +51,4 @@ jobs:

- name: Run Tests
run: |
pytest jhub_apps/${{ matrix.test_type }} -vvv -s
pytest jhub_apps/tests/${{ matrix.test_type }} -vvv -s
20 changes: 20 additions & 0 deletions .husky/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## Navigate to UI directory

cd ui

## Install Dependencies

npm install

## Run Build

npm run build

## Check for Uncommitted Build Files

if [ -n "$(git status --porcelain)" ]; then
echo "There are uncommitted changes. Please review the project for any uncommitted files before proceeding."
exit 1
else
echo "No uncommitted changes found."
fi
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,22 @@ http://127.0.0.1:10202/services/japps/docs
To try out authenticated endpoints click on the Authorize button on the top right of
the above url and chose `OAuth2AuthorizationCodeBearer` and click on Authorize.

## Developing Locally

_Note: In order to develop locally, both the JupyterHub backend and React UI frontend should be running._

1. To start the JupyterHub Backend, run the following in a terminal:

```bash
jupyterhub -f jupyterhub_config.py
```

2. To start the React UI frontend, run the following in a separate terminal from the `ui` directory:

```bash
npm run watch
```

## Running Tests

### Unit Tests
Expand All @@ -83,13 +99,13 @@ pytest jhub_apps/tests_e2e -vvv -s --headed
JHub Apps has been tested with local JupyterHub using `SimpleLocalProcessSpawner` and with
The Littlest JupyterHub using `SystemdSpawner`.

* Install JHub Apps
- Install JHub Apps

```python
pip install git+https://github.com/nebari-dev/jhub-apps.git
```

* Add the following in The Littlest JupyterHub's `jupyterhub_config.py`
- Add the following in The Littlest JupyterHub's `jupyterhub_config.py`

```python
from tljh.user_creating_spawner import UserCreatingSpawner
Expand Down
2 changes: 1 addition & 1 deletion jhub_apps/__about__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "2024.4.1"
__version__ = "2024.6.1"
8 changes: 7 additions & 1 deletion jhub_apps/configuration.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
from base64 import b64encode
from secrets import token_bytes

Expand All @@ -9,6 +10,9 @@


def _create_token_for_service():
# Use the one from environment if available
if os.environ.get("JHUB_APP_JWT_SECRET_KEY"):
return os.environ["JHUB_APP_JWT_SECRET_KEY"]
return b64encode(token_bytes(32)).decode()


Expand Down Expand Up @@ -88,7 +92,8 @@ def install_jhub_apps(c, spawner_to_subclass):
"admin:auth_state",
"access:services",
"list:services",
"read:services", # read service models
"read:services", # read service models,
"tokens", # ability to generate tokens for users to act as user
] + ([
"shares"
] if is_jupyterhub_5() else []),
Expand All @@ -99,6 +104,7 @@ def install_jhub_apps(c, spawner_to_subclass):
"scopes": [
"self",
"access:services",
"list:services",
"admin:auth_state",
],
},
Expand Down
124 changes: 111 additions & 13 deletions jhub_apps/hub_client/hub_client.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import typing
from concurrent.futures import ThreadPoolExecutor
from functools import wraps

import structlog
import os
Expand All @@ -17,22 +18,86 @@
logger = structlog.get_logger(__name__)


def requires_user_token(func):
"""Decorator to apply to methods of HubClient to create user token before
the method call and revoke them after the method call finishes.
"""
@wraps(func)
def wrapper(self, *args, **kwargs):
response_json = self._create_token_for_user()
token_id = response_json["id"]
try:
original_method_return = func(self, *args, **kwargs)
except Exception as e:
raise e
finally:
self._revoke_token(token_id=token_id)
return original_method_return
return wrapper


class HubClient:
def __init__(self, token=None):
self.token = token or JUPYTERHUB_API_TOKEN
def __init__(self, username=None):
self.username = username
self.tokens = [JUPYTERHUB_API_TOKEN]
self.token_json = None
self.jhub_apps_request_id = None
self._set_request_id()

def _set_request_id(self):
contextvars = structlog.contextvars.get_contextvars()
self.jhub_apps_request_id = contextvars.get("request_id")

def _headers(self):
def _headers(self, token=None):
header_token = token
if not token and self.tokens:
header_token = self.tokens[-1]

return {
"Authorization": f"token {self.token}",
"Authorization": f"token {token or header_token}",
"JHUB_APPS_REQUEST_ID": self.jhub_apps_request_id
}

def _create_token_for_user(self):
assert self.username
logger.info("Creating token for user", username=self.username)
r = requests.post(
API_URL + f"/users/{self.username}/tokens",
headers=self._headers(token=JUPYTERHUB_API_TOKEN),
json={
# Expire in 5 minutes max
"expires_in": 60*5
}
)
r.raise_for_status()
rjson = r.json()
# This is so that when a new token is created, it doesn't overrides a previously created token,
# which is still in use by the previous function in stack
# for e.g. When func_a calls func_b and both have the decorator "requires_user_token"
# The func_a on completing execution will only clear the token, which the decorator
# requires_user_token created for it, not the token created for func_a
self.token_json = rjson
self.tokens.append(rjson["token"])
logger.info(f"Created token: {rjson['id']}")
return rjson

def _revoke_token(self, token_id):
assert self.username
assert token_id
logger.info(f"Revoking token: {token_id}")
r = requests.delete(
API_URL + f"/users/{self.username}/tokens/{token_id}",
headers=self._headers(token=JUPYTERHUB_API_TOKEN),
)
r.raise_for_status()
logger.info(
"Token revoked",
status_code=r.status_code,
username=self.username
)
self.tokens.pop()
return r

def get_users(self):
r = requests.get(
API_URL + "/users",
Expand All @@ -43,16 +108,18 @@ def get_users(self):
users = r.json()
return users

def get_user(self, user):
@requires_user_token
def get_user(self, user=None):
r = requests.get(
API_URL + f"/users/{user}",
API_URL + f"/users/{user or self.username}",
params={"include_stopped_servers": True},
headers=self._headers()
)
r.raise_for_status()
user = r.json()
return user

@requires_user_token
def get_server(self, username, servername):
user = self.get_user(username)
for name, server in user["servers"].items():
Expand All @@ -69,6 +136,7 @@ def normalize_server_name(self, servername):
# Max limit for servername is 255 chars
return text[:240]

@requires_user_token
def start_server(self, username, servername):
if not servername:
logger.info("Starting JupyterLab server")
Expand All @@ -87,13 +155,15 @@ def start_server(self, username, servername):
r.raise_for_status()
return r.status_code, servername

@requires_user_token
def create_server(self, username: str, servername: str, user_options: UserOptions = None):
logger.info("Creating new server", user=username)
normalized_servername = self.normalize_server_name(servername)
unique_servername = f"{normalized_servername}-{uuid.uuid4().hex[:7]}"
logger.info("Normalized servername", servername=servername)
return self._create_server(username, unique_servername, user_options)

@requires_user_token
def edit_server(self, username: str, servername: str, user_options: UserOptions = None):
logger.info("Editing server", server_name=servername)
server = self.get_server(username, servername)
Expand Down Expand Up @@ -140,7 +210,11 @@ def _share_server(
raise ValueError("None of share_to_user or share_to_group provided")
share_with = share_to_group or share_to_user
logger.info(f"Sharing {username}/{servername} with {share_with}")
return requests.post(API_URL + url, headers=self._headers(), json=data)
return requests.post(
API_URL + url,
headers=self._headers(),
json=data
)

def _share_server_with_multiple_entities(
self,
Expand Down Expand Up @@ -189,8 +263,10 @@ def _revoke_shared_access(self, username: str, servername: str):
url = f"/shares/{username}/{servername}"
return requests.delete(API_URL + url, headers=self._headers())

def get_shared_servers(self, username: str):
@requires_user_token
def get_shared_servers(self, username: str = None):
"""List servers shared with user"""
username = username or self.username
if not is_jupyterhub_5():
logger.info("Unable to get shared servers as this feature is not available in JupyterHub < 5.x")
return []
Expand All @@ -201,6 +277,7 @@ def get_shared_servers(self, username: str):
shared_servers = rjson["items"]
return shared_servers

@requires_user_token
def delete_server(self, username, server_name, remove=False):
if server_name is None:
# Default server and not named server
Expand All @@ -212,6 +289,7 @@ def delete_server(self, username, server_name, remove=False):
r.raise_for_status()
return r.status_code

@requires_user_token
def get_services(self):
r = requests.get(API_URL + "/services", headers=self._headers())
r.raise_for_status()
Expand All @@ -223,17 +301,37 @@ def get_groups(self):
r.raise_for_status()
return r.json()

@requires_user_token
def get_user_scopes(self):
assert self.token_json
assert "scopes" in self.token_json
return self.token_json["scopes"]


def get_users_and_group_allowed_to_share_with(user):
"""Returns a list of users and groups"""
hclient = HubClient()
hclient = HubClient(username=user.name)
users = hclient.get_users()
user_names = [u["name"] for u in users if u["name"] != user.name]
groups = hclient.get_groups()
group_names = [group['name'] for group in groups]
# TODO: Filter users and groups based on what the user has access to share with
# parsed_scopes = parse_scopes(scopes)
user_scopes = hclient.get_user_scopes()
return {
"users": user_names,
"groups": group_names
"users": filter_entity_based_on_scopes(
scopes=user_scopes, entities=user_names
),
"groups": filter_entity_based_on_scopes(
scopes=user_scopes, entities=group_names, entity_key="group"
)
}


def filter_entity_based_on_scopes(scopes, entities, entity_key="user"):
# only available in JupyterHub>=5
from jupyterhub.scopes import has_scope, expand_scopes
allowed_entities_to_read = set()
expanded_scopes = expand_scopes(scopes)
for entity in entities:
if has_scope(f'read:{entity_key}s:name!{entity_key}={entity}', expanded_scopes):
allowed_entities_to_read.add(entity)
return list(allowed_entities_to_read)
Loading

0 comments on commit 9681cf9

Please sign in to comment.