Skip to content

add service account with allow-app-sharing-role permissions #2917

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

Draft
wants to merge 41 commits into
base: main
Choose a base branch
from

Conversation

Adam-D-Lewis
Copy link
Member

@Adam-D-Lewis Adam-D-Lewis commented Jan 21, 2025

Reference Issues or PRs

In order for startup apps to be used by nebari, we need to create a service account with appropriate permissions to create the apps and share them with others. One issue this caused was the auth state for the service account doesn't get populated and it is needed in our custom Spawner code. This PR updates the service account auth_state during the pre-spawn-hook.

What does this implement/fix?

Put a x in the boxes that apply

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds a feature)
  • Breaking change (fix or feature that would cause existing features not to work as expected)
  • Documentation Update
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no API changes)
  • Build related changes
  • Other (please describe):

Testing

  • Did you test the pull request locally?
  • Did you add new tests?

How to test this PR?

  1. Do a nebari deployment with the following config defined in the nebari config file.
jhub_apps:
  enabled: true
  overrides: {
    startup_apps: [
      {
        "username": "service-account-jupyterhub",  # app will be created by this user
        "servername": "my-startup-server",  # specify a unique server name
        "user_options": {
            "display_name": "My Startup Server",
            "description": "description",
            "thumbnail": "data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAFUlEQVR42mNkYPhfz0AEYBxVSF+FAP5FDvcfRYWgAAAAAElFTkSuQmCC",  # base64 encoded image data to use for thumbnail
            "filepath": "panel_basic.py",  # local file or path within git repo
            "framework": "panel",
            "public": False,  # Whether or not app is publicly accessible without authentication
            "keep_alive": False,  # Whether or not to shut down app after a period of idleness
            "env": {"MY_ENV_VAR": "MY_VALUE"},
            "repository": {"url": "https://github.com/nebari-dev/jhub-apps-from-git-repo-example.git"},  # specify if pulling app from git repo
            "conda_env": "global-panelenv",
            "profile": "small-instance",
            "share_with": {
              "users": [], 
              "groups": ["/admin"]
            },
        },
      },
    ]
  }
  1. Then create this conda env after deployment in the global namespace
name: panelenv
channels:
  - conda-forge
dependencies:
  - panel
  - jhsingle-native-proxy
  - bokeh-root-cmd
  - ipykernel
variables: {}
  1. Then create an admin user and give the user the jupyterhub client's "allow-app-sharing-role" role. Log in and make sure you can see my-startup-server listed as a shared app. Then open it and ensure it opens correctly.

Any other comments?

@Adam-D-Lewis
Copy link
Member Author

Adam-D-Lewis commented Jan 22, 2025

I'm seeing some issues right now. The issue is the "jhub-apps-sa" user which creates the startup_apps needs to have logged in before the app server can be started successfully. In the current design, the "jhub-apps-sa" user is meant to act more like a service account and not log in interactively as users do. The issues I've seen so far are in the code that is run by the Spawner to set preferred username and render profiles, but it's possible there are more. In those instances, the auth state for the "jhub-apps-sa" user is None before initial log in so an error is thrown once we try to access info in the auth_state object in those methods.

Some possible ideas on how to fix this:

  • if the auth_state is None, then go ask keycloak for the needed info? (login on behalf of the user maybe or some other way?)
  • log the "jhub-apps-sa" user in somehow on startup app creation?
  • I could add startup_app: True to the user options dict used by jhub_apps. I could then modify our problematic spawner code which requires auth_state to only run if the server starting up isn't a startup app.

@krassowski any thoughts on how best to do this?

@krassowski
Copy link
Member

This might be too radical and not the kind of suggestion you are asking for, but could it be solved on the jhub-apps level? I mean jhub-apps is meant to be auth-provider agnostic so it should be possible to make this work without touching keycloak at all? This might be just my PTSD from figuring out keycloak piping last time speaking. I recall @aktech also had pleasure to work on keycloak integration - he may have better ideas.

@krassowski
Copy link
Member

Some possible ideas on how to fix this:

  • if the auth_state is None, then go ask keycloak for the needed info? (login on behalf of the user maybe or some other way?)
  • log the "jhub-apps-sa" user in somehow on startup app creation?
  • I could add startup_app: True to the user options dict used by jhub_apps. I could then modify our problematic spawner code which requires auth_state to only run if the server starting up isn't a startup app.

If staying with this approach I would probably try the solutions in that exact order. I am not sure if the last one will work if you need to do anything beyond the configuration being set (i.e. whether server will actually spawn if you do not have auth_state).

@aktech
Copy link
Member

aktech commented Jan 24, 2025

This might be too radical and not the kind of suggestion you are asking for, but could it be solved on the jhub-apps level? I mean jhub-apps is meant to be auth-provider agnostic so it should be possible to make this work without touching keycloak at all?

That would be ideal, indeed. The way we are using the spawner here, expects the user to be logged in (hence populating auth_state) once before creating a server, which makes sense from jupyterhub pov as the servers are created by humans instead of robots.

This (startup apps) works in jhub-apps (without nebari) by default unless the spawner needs auth_state (which is the case here). Since its a feature of jhub-apps to provide the ability to create init apps on startup regardless of Authenticator or spawner, this should be handled in jhub-apps if possible (this is a big if though), you might need to pass in some kind of keycloak auth details in the JApps config, that might make it possible.

We can try to see if we can somehow call the authenticate method of the Authenticator in jhub-apps for the user, to populate the auth state before starting startup apps, but yes this might not be possible at all, in that case what you suggested in this comment #2917 (comment), sounds like a good approach, and I agree with @krassowski the last one won't work, as you need groups info to create nfs mounts.

Also, another thing to note here, is supporting this in jhub-apps might be tricky (if its possible), its probably ok to just go for your approach and we can tackle it in jhub-apps later, if time is of essence here.

@Adam-D-Lewis
Copy link
Member Author

Adam-D-Lewis commented Jan 27, 2025

I think the ideal solution is to create the startup apps up using jupyterhub-service-account which is service account associated with the jupyterhub Keycloak client. We already do the authenticatation needed in Nebari's KeycloakOAuthenticator. We just need to set auth_state for that service account after authentication. I'm not clear on how to set auth state for jupyterhub-service-account, but I'll dig in to the jupyterhub and jupyterhub/OAuthentication code further.

import json
import urllib.parse
import requests
import jwt

# def get_token():
client_id = "jupyterhub"
client_secret = "<my-secret>"
token_url = "https://github-actions.nebari.dev/auth/realms/nebari/protocol/openid-connect/token"

body = urllib.parse.urlencode({
    "client_id": client_id,
    "client_secret": client_secret,
    "grant_type": "client_credentials",
})

headers = {
    'Content-Type': 'application/x-www-form-urlencoded'
}

response = requests.post(token_url, data=body, headers=headers, verify=False)
data = response.json()

# Get the token
token = data["access_token"]

# Decode and print token contents
decoded = jwt.decode(token, options={"verify_signature": False})
print(json.dumps(decoded, indent=2))

yields

{
  "exp": 1737998505,
  "iat": 1737998205,
  "jti": "9827b1e3-9612-4341-8076-22e99d2ccb04",
  "iss": "https://github-actions.nebari.dev/auth/realms/nebari",
  "aud": [
    "realm-management",
    "grafana",
    "argo-server-sso",
    "conda_store",
    "account"
  ],
  "sub": "c1da7cbd-3150-42ae-8b19-c9b4304f2054",
  "typ": "Bearer",
  "azp": "jupyterhub",
  "acr": "1",
  "realm_access": {
    "roles": [
      "offline_access",
      "default-roles-nebari",
      "uma_authorization"
    ]
  },
  "resource_access": {
    "realm-management": {
      "roles": [
        "view-realm",
        "view-users",
        "view-clients",
        "query-clients",
        "query-groups",
        "query-users"
      ]
    },
    "jupyterhub": {
      "roles": [
        "allow-read-access-to-services-role",
        "jupyterhub_developer",
        "allow-group-directory-creation-role"
      ]
    },
    "grafana": {
      "roles": [
        "grafana_viewer"
      ]
    },
    "argo-server-sso": {
      "roles": [
        "argo-viewer"
      ]
    },
    "conda_store": {
      "roles": [
        "conda_store_developer"
      ]
    },
    "account": {
      "roles": [
        "manage-account",
        "manage-account-links",
        "view-profile"
      ]
    }
  },
  "scope": "email profile",
  "clientHost": "10.244.0.1",
  "email_verified": false,
  "clientId": "jupyterhub",
  "roles": [
    "view-realm",
    "view-users",
    "view-clients",
    "query-clients",
    "query-groups",
    "query-users",
    "allow-read-access-to-services-role",
    "jupyterhub_developer",
    "allow-group-directory-creation-role",
    "grafana_viewer",
    "argo-viewer",
    "conda_store_developer",
    "manage-account",
    "manage-account-links",
    "view-profile"
  ],
  "groups": [
    "/analyst",
    "/users"
  ],
  "preferred_username": "service-account-jupyterhub",
  "clientAddress": "10.244.0.1"
}

which has the group membership for the service account, preferred_username, and permissions similar to a normal user.

@Adam-D-Lewis
Copy link
Member Author

Adam-D-Lewis commented Jan 28, 2025

I haven't been able to successfully set auth state for the service account, (Update: resolved by commit 110b0ee (next commit)) but just looking up the service account's info during the spawner code in this commit seems to work.

@Adam-D-Lewis Adam-D-Lewis changed the title add jhub apps service account with admin permissions add jhub apps service account with create-share-apps permissions Jan 28, 2025
@Adam-D-Lewis Adam-D-Lewis changed the title add jhub apps service account with create-share-apps permissions add service account with allow-app-sharing-role permissions Jan 28, 2025
data "keycloak_role" "main-service" {
for_each = toset(var.service-account-roles)
# Get client data for each service account client
data "keycloak_openid_client" "service_clients" {
Copy link
Member Author

Choose a reason for hiding this comment

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

Before we only allowed service accounts to get roles from the realm-management client. This PR allows us to set roles by any client. This functionality was needed to be able to set the allow-app-sharing-role on the jupyterhub service account.

@gen.coroutine
def get_username_hook(spawner):
auth_state = yield spawner.user.get_auth_state()
async def get_username_hook(spawner):
Copy link
Member Author

Choose a reason for hiding this comment

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

flyby: tornado coroutine -> native coroutine. We don't need to use a tornado coroutine.

Copy link
Member

@aktech aktech left a comment

Choose a reason for hiding this comment

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

Looks good overall, I haven't had the chance to test it locally due to M1 issues, but will try to sort that out tomorrow.

How do you feel about adding a test here verifying the service account has correct roles?

Comment on lines +50 to +66
token_info = await self._get_token_info()

# Get user info using the access token
user_info = await self.token_to_user(token_info)

# Get/set username
username = self.user_info_to_username(user_info)
username = self.normalize_username(username)

# Build auth model similar to OAuth flow
auth_model = {
"name": username,
"admin": True if username in self.admin_users else None,
"auth_state": self.build_auth_state_dict(token_info, user_info),
}

auth_model = await self.update_auth_model(auth_model)
Copy link
Member

Choose a reason for hiding this comment

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

We should add a note here and link to the JupyterHub code for posterity, incase something changes in JupyterHub, we can catch-up with that.

https://github.com/jupyterhub/oauthenticator/blob/d31bb193e84e7cda58b16f2f5d385c9b8affda4f/oauthenticator/oauth2.py#L1436

Copy link
Member Author

Choose a reason for hiding this comment

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

done

@Adam-D-Lewis Adam-D-Lewis requested a review from aktech February 3, 2025 16:27
@Adam-D-Lewis
Copy link
Member Author

Adam-D-Lewis commented Feb 5, 2025

I was originally planning on adding a test once I had a ragna distribution running that would test this, but I'll add a test for just this part (startup_apps).

@Adam-D-Lewis Adam-D-Lewis requested a review from a team as a code owner February 10, 2025 17:51
@Adam-D-Lewis
Copy link
Member Author

Adam-D-Lewis commented Feb 10, 2025

Looks good overall, I haven't had the chance to test it locally due to M1 issues, but will try to sort that out tomorrow.

How do you feel about adding a test here verifying the service account has correct roles?

@aktech, I added this test now in 80456c5, but it failed since test-user is not an admin. The test uses his jupyterhub token so I make the test-user an admin and it now works as expected.

@aktech
Copy link
Member

aktech commented Feb 11, 2025

@Adam-D-Lewis
Copy link
Member Author

Adam-D-Lewis commented Feb 11, 2025

Looks like its still failing: nebari-dev/nebari/actions/runs/13252428164/job/36993020527#step:13:220 (?)

Thanks for pointing that out, @aktech. Yeah, this won't pass until we do a jhub_apps release and include the latest in the jupyterhub image used by nebari. I opened an issue about the jhub-apps release. I'll convert to draft for now.

@Adam-D-Lewis Adam-D-Lewis marked this pull request as draft February 11, 2025 17:01
@Adam-D-Lewis
Copy link
Member Author

Amit reminded me that the startup app expects a global-mypanel env which we aren't creating and I'll need to try to switch it over to the nebari-git/dashboards one or build the global-mypanel env if we want to actually try to open the startup server.

@viniciusdc
Copy link
Contributor

Amit reminded me that the startup app expects a global-mypanel env which we aren't creating and I'll need to try to switch it over to the nebari-git/dashboards one or build the global-mypanel env if we want to actually try to open the startup server.

I am curious on how that will work, let me know if you need any help.

@Adam-D-Lewis
Copy link
Member Author

Amit reminded me that the startup app expects a global-mypanel env which we aren't creating and I'll need to try to switch it over to the nebari-git/dashboards one or build the global-mypanel env if we want to actually try to open the startup server.

I am curious on how that will work, let me know if you need any help.

According to @aktech, we would need to add:

so it should be straight forward.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Changes requested 🧱
Development

Successfully merging this pull request may close these issues.

4 participants