Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Filter environments by user access #2940

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,133 @@ def generate_paged_urls(base_url: str, total_records: int, page_size: int) -> li
return urls


def get_scoped_token(
conda_store_url: str,
admin_token: str,
name: str,
groups: list[str] = None,
admin: bool = False,
):
"""Generate a conda store auth token for a given set of groups. The
token will only have `view` permissions for each group (regardless if
the user has higher permissions associated with the group generally).

If the user is an admin, then the token will have `view` permissions
for all namespaces.

By default, the user will have view permissions for the following
groups:
- `default/*`
- `filesystem/*`
- `nebari-git/*`
- `global/*`

Parameters
----------
conda_store_url : str
The URL of the Conda Store instance.
admin_token : str
The admin token used to authenticate with the Conda Store API.
name : str
The name of the user for whom the token will be generated (the token
will have `view` permission to this namespace).
groups : list[str], optional
A list of group names that the user should have access to. If not provided,
default roles will be assigned.
admin : bool, optional
Whether the user is an admin. If true, the token will have `view` permissions
for all namespaces. Defaults to False.

Returns
-------
str
The generated token.
"""
import urllib3

token_endpoint = f"http://{conda_store_url}/conda-store/api/v1/token/"
http = urllib3.PoolManager()

# add default role bindings
role_bindings = {
"role_bindings": {
f"{name}/*": ["viewer"],
"default/*": ["viewer"],
"filesystem/*": ["viewer"],
"nebari-git/*": ["viewer"],
"global/*": ["viewer"],
}
}

# add role bindings for all the groups the user is part of
if groups is not None:
for group in groups:
group = group.replace("/", "")
role_bindings["role_bindings"][f"{group}/*"] = ["viewer"]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is a pretty big assumption here, that the keycloak groups map directly to the conda-store namespaces. Is that true for nebari?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no direct way to create a new namespace through the ui, and group names do match namespaces, though there is also a namespace per user that matches their user name.

That being said, it is possible to create a new namespace and give permissions to use it with an arbitrary name using the conda-store api.

Additionally, some groups, like admins, have access to more namespaces than just their own.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a doc (or piece of existing code) that describes the relationship between keycloak permissions/groups/etc and conda-store privileges? I want to be able to ensure the user is able to see all the namespaces+environments it has access to.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is custom auth logic at

which does the mapping

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmmm, I see that's not quite what I'm looking for. The user_info dict provided ultimately comes from the jupyter api /users endpoint https://jupyterhub.readthedocs.io/en/5.2.1/reference/rest-api.html#operation/get-user which provides the users roles and groups. Here is an example of the output

{
  "name": "string",
  "admin": true,
  "roles": [
    "string"
  ],
  "groups": [
    "string"
  ],
  "server": "string",
  "pending": "spawn",
  "last_activity": "2019-08-24T14:15:22Z",
  "servers": {
  },
  "auth_state": {}
}

Is there some piece of code or docs that describe the relationship between hub users and keycloak users or conda store permissions?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@viniciusdc do you have any insight here for @soapy1?

Copy link
Member

@krassowski krassowski Feb 11, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

relationship between hub users and keycloak users

Chiming in as I worked on this - both groups and roles are taken from Keycloak since:

@viniciusdc can confirm if anything changed since as I was not reviewing PRs that followed, but I suspect this is still the case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @krassowski those links were very helpful in piecing together a model of what is going on here. This part of the authenticator is also notable https://github.com/nebari-dev/nebari/blob/main/src/_nebari/stages/kubernetes_services/template/modules/kubernetes/services/conda-store/config/conda_store_config.py#L415.

I think this implementation where the permissions are constructed based on the group provided by the Jupyter user object should be correct. I added a few extra comments to explain the motivation.

This was pretty convoluted to piece together. I think it would be good if at least one other person also went thru the exercise of following the permissions flow from keycloak thru Jupyter and conda-store to double check that this approach is valid.

It would be nice if there was some kind of document that outlined the intended design of how permissions work in nebari. Maybe as part of the tasks in this issue #2304?


# if the user is an admin, they can view all namespace + environments
if admin:
role_bindings["role_bindings"]["*/*"] = ["viewer"]

encoded_body = json.dumps(role_bindings)

# generate a token with with the generated role bindings
token_response = http.request(
"POST",
str(token_endpoint),
headers={
"Authorization": f"Bearer {admin_token}",
"Content-Type": "application/json",
},
body=encoded_body,
)
token_data = json.loads(token_response.data.decode("UTF-8"))
return token_data.get("data", {}).get("token")


# TODO: this should get unit tests. Currently, since this is not a python module,
# adding tests in a traditional sense is not possible. See https://github.com/soapy1/nebari/tree/try-unit-test-spawner
# for a demo on one approach to adding test.
def get_conda_store_environments(user_info: dict):
"""Gets the conda-store environments for a given user using the v1 environment
API.

This scopes permissions for the given user using the `groups` field in the
user_info dict. The user_info dict comes from Jupyter's user dict. The groups
in this dict come from the keycloak groups, which include the list of conda-store
namespaces the user has access to. For the purpose of this function, we can assume
that if the user is part of the group, it at least has `view` permissions on the
conda-store namespaces.

Parameters
----------
user_info : dict
A dictionary containing user information originating from the JupyterHub
user info. The scheme of the user info is:
```
{
"name": "string",
"admin": true,
"roles": [
"string"
],
"groups": [
"string"
],
"server": "string",
"pending": "spawn",
"last_activity": "2019-08-24T14:15:22Z",
"servers": {
},
"auth_state": {}
}
```

Returns
-------
list[str]
A list of all conda-store environments for the given user
"""
import os

import urllib3
Expand All @@ -65,27 +188,35 @@ def get_conda_store_environments(user_info: dict):
endpoint = "conda-store/api/v1/environment"

base_url = f"http://{external_url}/{endpoint}/"
http = urllib3.PoolManager()

groups = user_info["groups"]
name = user_info["name"]
admin = user_info["admin"]
# get token with appropriate scope for the user making the request
scoped_token = get_scoped_token(external_url, token, name, groups, admin)

# get total number of records from the endpoint
total_records = get_total_records(base_url, token)
total_records = get_total_records(base_url, scoped_token)

# will contain all the environment info returned from the api
env_data = []

# generate a list of urls to hit to build the response
urls = generate_paged_urls(base_url, total_records, page_size)

http = urllib3.PoolManager()

# get content from urls
for url in urls:
response = http.request(
"GET", url, headers={"Authorization": f"Bearer {token}"}
"GET", url, headers={"Authorization": f"Bearer {scoped_token}"}
)
decoded_response = json.loads(response.data.decode("UTF-8"))
env_data += decoded_response.get("data", [])

# Filter and return conda environments for the user
return [f"{env['namespace']['name']}-{env['name']}" for env in env_data]
envs = [f"{env['namespace']['name']}-{env['name']}" for env in env_data]
return envs


c.Spawner.pre_spawn_hook = get_username_hook
Expand Down
Loading