-
Notifications
You must be signed in to change notification settings - Fork 7
Add internal vs hackathon Jupyter spaces #88
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
Open
kirillmitrofanov
wants to merge
2
commits into
main
Choose a base branch
from
feat/two-jupyter-spaces
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| # Two Jupyter spaces (internal vs hackathon) | ||
|
|
||
| This is the operator runbook for the two shared Labs. It is **not** per-user | ||
| container isolation. | ||
|
|
||
| ## Honesty bound | ||
|
|
||
| | Boundary | Mechanism | Strength | | ||
| |----------|-----------|----------| | ||
| | Hackathon vs internal | Separate bind-mounts (`codes/` vs `codes-hackathon/`) | **Real** | | ||
| | Alice vs Bob inside one Lab | ContentsManager hides dirs using a viewer token | **Visual only** | | ||
| | App project/node lists | Postgres `tenant` + existing visibility | **Real** for GUI/API | | ||
|
|
||
| The kernel and terminal in a Lab can still `ls` every path **mounted in that | ||
| Lab**. Do not treat JupyterLab as a security boundary inside a group. | ||
|
|
||
| ## Hub users | ||
|
|
||
| FirstUse (or Dummy in local compose). Two accounts, operator-managed passwords: | ||
|
|
||
| | Hub user | Container name | Host tree | | ||
| |----------|----------------|-----------| | ||
| | `internal` (legacy `user1` still allowed during cutover) | `jupyter-internal` | `codes/projects`, `codes/nodes` | | ||
| | `hackathon` | `jupyter-hackathon` | `codes-hackathon/projects`, `codes-hackathon/nodes` | | ||
|
|
||
| The app stays on Keycloak. Frontend/backend pick `/user/internal/` vs | ||
| `/user/hackathon/` from the user's tenant. | ||
|
|
||
| ## Keycloak groups | ||
|
|
||
| Create realm groups (or roles) and a mapper that puts them in the access token | ||
| (`groups` claim or `realm_access.roles`): | ||
|
|
||
| - `nw-internal` — project members (default for existing users) | ||
| - `nw-hackathon` — temporary / outside users | ||
| - `node-reviewers` — can approve submitted nodes **in their own tenant** | ||
|
|
||
| On login Django syncs `nw-internal` / `nw-hackathon` onto Django Groups. | ||
| If the token has no tenant claim, existing membership is left as-is; users with | ||
| neither group are assigned `nw-internal`. | ||
|
|
||
| `internal` wins if a user is in both groups. | ||
|
|
||
| ## Env (compose) | ||
|
|
||
| ``` | ||
| JUPYTERHUB_ALLOWED_USERS=internal,hackathon,user1 | ||
| JUPYTER_GRANT_SUDO=no | ||
| JUPYTER_MEM_LIMIT=8G # tune: ~half remaining RAM per Lab | ||
| JUPYTER_CPU_LIMIT=4 | ||
| HOST_PROJECT_PATH=.../django-project | ||
| # HOST_HACKATHON_PATH defaults to $HOST_PROJECT_PATH/codes-hackathon | ||
| ``` | ||
|
|
||
| Do not publish Jupyter/Docker ports on `0.0.0.0`. Hub stays behind nginx | ||
| `/jupyter`. | ||
|
|
||
| ## Cutover (needs explicit OK — this recreates Labs) | ||
|
|
||
| 1. `mkdir -p gui/workflow_backend/django-project/codes-hackathon/{projects,nodes}` | ||
| 2. Deploy this branch; **backend migrate** applies `tenant` + node governance. | ||
| 3. Create Hub users `internal` and `hackathon` (FirstUse: first login sets | ||
| password). Keep `user1` until internal users have moved. | ||
| 4. Recreate JupyterHub so spawners pick up volume maps. **Warn:** this drops | ||
| running kernels; ssh-agent on the backend is unrelated unless backend also | ||
| restarts. | ||
| 5. Smoke: | ||
| - Guest Keycloak user: app lists only hackathon projects; Lab tree is | ||
| `codes-hackathon` only (`ls /home/jovyan/codes/projects` has no internal | ||
| UUIDs). | ||
| - Internal user: app hides hackathon tenant; Lab is the internal tree; | ||
| file browser omits others' private UUIDs; `ls` in the terminal still sees | ||
| them (expected). | ||
| - Approve a node in one tenant; it does not appear in the other tenant's | ||
| palette. | ||
| 6. Rotate the two Lab passwords; document them in the operator secret store, | ||
| not git. | ||
|
|
||
| ## Rollback | ||
|
|
||
| - Revert the git deploy. | ||
| - Hub `allowed_users=user1` and the previous volume map (all of `codes/`). | ||
| - DB columns `tenant` / node `status` are backward compatible (defaults | ||
| `internal` / catalog `public`). | ||
|
|
||
| ## Node governance | ||
|
|
||
| `private → submitted → approved → public` | ||
|
|
||
| - New uploads: `private` in the caller's tenant. | ||
| - Catalog files (`uploaded_by` null): `public` + `tenant=internal` after | ||
| migrate. | ||
| - Palette: same-tenant `public`, plus the owner's own non-public nodes. | ||
| Reviewers also see `submitted` in their tenant. | ||
| - Endpoints under `/api/box/files/<uuid>/submit|approve|publish|reject/` and | ||
| `/api/box/review-queue/`. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
145 changes: 145 additions & 0 deletions
145
gui/workflow_backend/django-project/app/box/governance.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| """Node governance: private → submitted → approved → public.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from django.db.models import Q | ||
| from django.utils import timezone | ||
| from rest_framework.exceptions import PermissionDenied, ValidationError | ||
|
|
||
| from app.tenants import get_user_tenant, is_node_reviewer | ||
| from app.box.models import NodeAuditLog, PythonFile | ||
|
|
||
|
|
||
| def visible_python_files(user): | ||
| tenant = get_user_tenant(user) | ||
| qs = PythonFile.objects.filter(is_active=True, tenant=tenant) | ||
|
kirillmitrofanov marked this conversation as resolved.
Outdated
|
||
| own = Q(uploaded_by=user) | ||
| public = Q(status=PythonFile.Status.PUBLIC) | Q(uploaded_by__isnull=True) | ||
| if is_node_reviewer(user): | ||
| review = Q(status=PythonFile.Status.SUBMITTED) | ||
|
kirillmitrofanov marked this conversation as resolved.
Outdated
|
||
| return qs.filter(own | public | review) | ||
| return qs.filter(own | public) | ||
|
|
||
|
|
||
| def log_node_event(python_file, *, actor, action, from_status="", to_status="", comment=""): | ||
| NodeAuditLog.objects.create( | ||
| python_file=python_file, | ||
| actor=actor, | ||
| action=action, | ||
| from_status=from_status or "", | ||
| to_status=to_status or "", | ||
| comment=comment or "", | ||
| tenant=python_file.tenant, | ||
| ) | ||
|
|
||
|
|
||
| def submit_node(python_file, user): | ||
| if python_file.uploaded_by_id != user.id: | ||
| raise PermissionDenied("Only the owner can submit this node.") | ||
| if python_file.status != PythonFile.Status.PRIVATE: | ||
| raise ValidationError("Only private nodes can be submitted.") | ||
| previous = python_file.status | ||
| python_file.status = PythonFile.Status.SUBMITTED | ||
| python_file.submitted_at = timezone.now() | ||
| python_file.review_comment = "" | ||
| python_file.save( | ||
| update_fields=["status", "submitted_at", "review_comment", "updated_at"] | ||
| ) | ||
| log_node_event( | ||
| python_file, | ||
| actor=user, | ||
| action="submitted", | ||
| from_status=previous, | ||
| to_status=python_file.status, | ||
| ) | ||
| return python_file | ||
|
|
||
|
|
||
| def approve_node(python_file, user, *, make_public: bool = False, comment: str = ""): | ||
|
kirillmitrofanov marked this conversation as resolved.
|
||
| if not is_node_reviewer(user): | ||
| raise PermissionDenied("Node reviewers only.") | ||
| if python_file.status != PythonFile.Status.SUBMITTED: | ||
| raise ValidationError("Only submitted nodes can be approved.") | ||
| previous = python_file.status | ||
| python_file.status = ( | ||
| PythonFile.Status.PUBLIC if make_public else PythonFile.Status.APPROVED | ||
| ) | ||
| python_file.reviewed_at = timezone.now() | ||
| python_file.reviewed_by = user | ||
| python_file.review_comment = comment or "" | ||
| python_file.save( | ||
| update_fields=[ | ||
| "status", | ||
| "reviewed_at", | ||
| "reviewed_by", | ||
| "review_comment", | ||
| "updated_at", | ||
| ] | ||
| ) | ||
| log_node_event( | ||
| python_file, | ||
| actor=user, | ||
| action="published" if make_public else "approved", | ||
| from_status=previous, | ||
| to_status=python_file.status, | ||
| comment=comment, | ||
| ) | ||
| return python_file | ||
|
|
||
|
|
||
| def publish_node(python_file, user, *, comment: str = ""): | ||
| if not is_node_reviewer(user): | ||
| raise PermissionDenied("Node reviewers only.") | ||
| if python_file.status not in ( | ||
| PythonFile.Status.APPROVED, | ||
| PythonFile.Status.SUBMITTED, | ||
| ): | ||
| raise ValidationError("Only approved or submitted nodes can be published.") | ||
| previous = python_file.status | ||
| python_file.status = PythonFile.Status.PUBLIC | ||
| python_file.reviewed_at = timezone.now() | ||
| python_file.reviewed_by = user | ||
| if comment: | ||
| python_file.review_comment = comment | ||
| python_file.save( | ||
| update_fields=["status", "reviewed_at", "reviewed_by", "review_comment", "updated_at"] | ||
| ) | ||
| log_node_event( | ||
| python_file, | ||
| actor=user, | ||
| action="published", | ||
| from_status=previous, | ||
| to_status=python_file.status, | ||
| comment=comment, | ||
| ) | ||
| return python_file | ||
|
|
||
|
|
||
| def reject_node(python_file, user, *, comment: str = ""): | ||
| if not is_node_reviewer(user): | ||
| raise PermissionDenied("Node reviewers only.") | ||
| if python_file.status != PythonFile.Status.SUBMITTED: | ||
| raise ValidationError("Only submitted nodes can be rejected.") | ||
| previous = python_file.status | ||
| python_file.status = PythonFile.Status.PRIVATE | ||
| python_file.reviewed_at = timezone.now() | ||
| python_file.reviewed_by = user | ||
| python_file.review_comment = comment or "" | ||
| python_file.save( | ||
| update_fields=[ | ||
| "status", | ||
| "reviewed_at", | ||
| "reviewed_by", | ||
| "review_comment", | ||
| "updated_at", | ||
| ] | ||
| ) | ||
| log_node_event( | ||
| python_file, | ||
| actor=user, | ||
| action="rejected", | ||
| from_status=previous, | ||
| to_status=python_file.status, | ||
| comment=comment, | ||
| ) | ||
| return python_file | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.