Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
96 changes: 96 additions & 0 deletions deployment/JUPYTER_TWO_SPACES.md
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/`.
6 changes: 6 additions & 0 deletions gui/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ HOST_PROJECT_PATH=/path/to/neuro-workflow/gui/workflow_backend/django-project

# JupyterHub
# JUPYTERHUB_API_TOKEN=dev-token-change-in-production
# Two shared Labs (see deployment/JUPYTER_TWO_SPACES.md):
# JUPYTERHUB_ALLOWED_USERS=internal,hackathon,user1
# HOST_HACKATHON_PATH defaults to $HOST_PROJECT_PATH/codes-hackathon
# JUPYTER_MEM_LIMIT=8G
# JUPYTER_CPU_LIMIT=4
# JUPYTER_GRANT_SUDO=no

# Keycloak admin credentials (change for production!)
# KEYCLOAK_ADMIN=admin
Expand Down
1 change: 1 addition & 0 deletions gui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ rename env.template to .env and set environment variables
|---|---|
| `NODES_DIR` | Path to the nodes directory (`./workflow_backend/django-project/codes/nodes`) |
| `HOST_PROJECT_PATH` | Absolute path to `gui/workflow_backend/django-project` on the host machine |
| `JUPYTERHUB_ALLOWED_USERS` | Hub accounts: `internal,hackathon,user1` (see `deployment/JUPYTER_TWO_SPACES.md`) |

Add 2 more .env files based on the templates.

Expand Down
4 changes: 3 additions & 1 deletion gui/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ services:
environment:
- DOCKER_HOST=unix:///var/run/docker.sock
- JUPYTERHUB_API_TOKEN=${JUPYTERHUB_API_TOKEN:-dev-token-change-in-production}
- JUPYTERHUB_ALLOWED_USERS=${JUPYTERHUB_ALLOWED_USERS:-user1}
- JUPYTERHUB_ALLOWED_USERS=${JUPYTERHUB_ALLOWED_USERS:-internal,hackathon,user1}
- JUPYTERHUB_AUTHENTICATOR=firstuse
- JUPYTERHUB_BASE_URL=${JUPYTERHUB_BASE_URL:-/jupyter/}
- JUPYTERHUB_COOKIE_SECURE=true
- JUPYTERHUB_COOKIE_SAMESITE=None
- JUPYTERHUB_FRAME_ORIGIN=${JUPYTERHUB_FRAME_ORIGIN:-https://snnbuilder.riken.jp}
- JUPYTER_GRANT_SUDO=no
- JUPYTER_MEM_LIMIT=${JUPYTER_MEM_LIMIT:-8G}
- JUPYTER_CPU_LIMIT=${JUPYTER_CPU_LIMIT:-4}

frontend:
build:
Expand Down
9 changes: 8 additions & 1 deletion gui/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ services:
- ./workflow_backend:/django-app
- ${NODES_DIR}:/django-app/django-project/codes/nodes
- ./workflow_backend/django-project/codes/projects:/django-app/django-project/codes/projects
- ./workflow_backend/django-project/codes-hackathon/projects:/django-app/django-project/codes-hackathon/projects
- ./workflow_backend/django-project/codes-hackathon/nodes:/django-app/django-project/codes-hackathon/nodes
- ../src:/django-app/src:ro
working_dir: /django-app
ports:
Expand All @@ -36,7 +38,7 @@ services:
- MCP_SERVER_URL=http://mcp:8001
- JUPYTERHUB_API_TOKEN=${JUPYTERHUB_API_TOKEN:-dev-token-change-in-production}
- JUPYTERHUB_BASE_URL=${JUPYTERHUB_BASE_URL:-/jupyter/}
- JUPYTER_EXECUTION_USER=${JUPYTER_EXECUTION_USER:-user1}
- JUPYTER_EXECUTION_USER=${JUPYTER_EXECUTION_USER:-internal}
depends_on:
- db
restart: unless-stopped
Expand All @@ -57,6 +59,11 @@ services:
# The spawner forwards this into each single-user container.
- ANTHROPIC_MODEL=${ANTHROPIC_MODEL:-}
- JUPYTERHUB_BASE_URL=${JUPYTERHUB_BASE_URL:-/jupyter/}
- JUPYTERHUB_ALLOWED_USERS=${JUPYTERHUB_ALLOWED_USERS:-internal,hackathon,user1}
- HOST_HACKATHON_PATH=${HOST_HACKATHON_PATH:-}
- JUPYTER_MEM_LIMIT=${JUPYTER_MEM_LIMIT:-}
- JUPYTER_CPU_LIMIT=${JUPYTER_CPU_LIMIT:-}
- JUPYTER_GRANT_SUDO=${JUPYTER_GRANT_SUDO:-no}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:rw
- ./workflow_backend/django-project/neuroworkflow/jupyterhub_config.py:/srv/jupyterhub/jupyterhub_config.py:ro
Expand Down
3 changes: 3 additions & 0 deletions gui/env.template
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,6 @@ ANTHROPIC_API_KEY="sk-ant-xxx"
ANTHROPIC_MODEL=""

JUPYTERHUB_API_TOKEN=dev-token-change-in-production
# Two shared Labs: internal (codes/) and hackathon (codes-hackathon/).
# JUPYTERHUB_ALLOWED_USERS=internal,hackathon,user1
# HOST_HACKATHON_PATH=/path/to/django-project/codes-hackathon
14 changes: 13 additions & 1 deletion gui/workflow_backend/django-project/app/auth/authViews.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response

from app.tenants import (
JUPYTER_HONESTY_NOTICE,
get_user_tenant,
hub_username_for_tenant,
is_node_reviewer,
)


@api_view(["GET"])
@authentication_classes([])
Expand Down Expand Up @@ -36,6 +43,7 @@ def protected_view(request):
@permission_classes([IsAuthenticated])
def user_profile(request):
"""User information acquisition"""
tenant = get_user_tenant(request.user)
return Response(
{
"user": {
Expand All @@ -50,7 +58,11 @@ def user_profile(request):
if request.user.last_login
else None
),
}
"tenant": tenant,
"hub_user": hub_username_for_tenant(tenant),
"is_node_reviewer": is_node_reviewer(request.user),
},
"notice": JUPYTER_HONESTY_NOTICE,
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,4 +231,7 @@ def authenticate_credentials(self, token):
},
email_verified=bool(payload.get("email_verified", False)),
)
from app.tenants import sync_user_tenant_from_payload

sync_user_tenant_from_payload(user, payload)
Comment thread
kirillmitrofanov marked this conversation as resolved.
return (user, token)
145 changes: 145 additions & 0 deletions gui/workflow_backend/django-project/app/box/governance.py
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)
Comment thread
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)
Comment thread
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 = ""):
Comment thread
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
Loading