diff --git a/deployment/JUPYTER_TWO_SPACES.md b/deployment/JUPYTER_TWO_SPACES.md new file mode 100644 index 00000000..003973f8 --- /dev/null +++ b/deployment/JUPYTER_TWO_SPACES.md @@ -0,0 +1,103 @@ +# 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 nodes even if the + owner's Keycloak tenant later changes (there is no tenant-move tool; old + rows stay on the original tenant). Reviewers also see `submitted` and + `approved` in their tenant. A reviewer cannot approve or publish a node + they uploaded; a second reviewer is required. +- `approve` with an empty body leaves the node `approved` (not public). + Reviewers can then `publish` it. Identical-content re-uploads do not + steal `uploaded_by`. Hackathon node bytes are stored under + `codes-hackathon/nodes/`, not the internal `MEDIA_ROOT` tree. +- Endpoints under `/api/box/files//submit|approve|publish|reject/` and + `/api/box/review-queue/`. diff --git a/gui/.env.example b/gui/.env.example index 3bd092c1..a35b28fe 100644 --- a/gui/.env.example +++ b/gui/.env.example @@ -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 diff --git a/gui/README.md b/gui/README.md index 2fe2f2cb..7fd5f1bf 100644 --- a/gui/README.md +++ b/gui/README.md @@ -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. diff --git a/gui/docker-compose.prod.yml b/gui/docker-compose.prod.yml index dca98eef..b6002682 100644 --- a/gui/docker-compose.prod.yml +++ b/gui/docker-compose.prod.yml @@ -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: diff --git a/gui/docker-compose.yml b/gui/docker-compose.yml index 17cc499c..22d06421 100644 --- a/gui/docker-compose.yml +++ b/gui/docker-compose.yml @@ -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: @@ -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 @@ -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 diff --git a/gui/env.template b/gui/env.template index 9133bdfa..c5ad1bc0 100644 --- a/gui/env.template +++ b/gui/env.template @@ -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 diff --git a/gui/workflow_backend/django-project/app/auth/authViews.py b/gui/workflow_backend/django-project/app/auth/authViews.py index 80372bf3..ffd160a1 100644 --- a/gui/workflow_backend/django-project/app/auth/authViews.py +++ b/gui/workflow_backend/django-project/app/auth/authViews.py @@ -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([]) @@ -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": { @@ -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, } ) diff --git a/gui/workflow_backend/django-project/app/auth/authentication.py b/gui/workflow_backend/django-project/app/auth/authentication.py index b25bce64..1a5cc250 100644 --- a/gui/workflow_backend/django-project/app/auth/authentication.py +++ b/gui/workflow_backend/django-project/app/auth/authentication.py @@ -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) return (user, token) diff --git a/gui/workflow_backend/django-project/app/box/governance.py b/gui/workflow_backend/django-project/app/box/governance.py new file mode 100644 index 00000000..f00a677e --- /dev/null +++ b/gui/workflow_backend/django-project/app/box/governance.py @@ -0,0 +1,155 @@ +"""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) + own = Q(uploaded_by=user) + in_tenant = Q(tenant=tenant) + public = Q(status=PythonFile.Status.PUBLIC) | Q(uploaded_by__isnull=True) + if is_node_reviewer(user): + review = Q( + status__in=(PythonFile.Status.SUBMITTED, PythonFile.Status.APPROVED) + ) + return qs.filter(own | (in_tenant & (public | review))) + return qs.filter(own | (in_tenant & 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 _reject_self_review(python_file, user): + if python_file.uploaded_by_id and python_file.uploaded_by_id == user.id: + raise PermissionDenied("A different reviewer must approve this node.") + + +def approve_node(python_file, user, *, make_public: bool = False, comment: str = ""): + if not is_node_reviewer(user): + raise PermissionDenied("Node reviewers only.") + _reject_self_review(python_file, user) + 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.") + _reject_self_review(python_file, user) + 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 diff --git a/gui/workflow_backend/django-project/app/box/migrations/0006_pythonfile_tenant_governance.py b/gui/workflow_backend/django-project/app/box/migrations/0006_pythonfile_tenant_governance.py new file mode 100644 index 00000000..0ab16a40 --- /dev/null +++ b/gui/workflow_backend/django-project/app/box/migrations/0006_pythonfile_tenant_governance.py @@ -0,0 +1,139 @@ +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def backfill_node_governance(apps, schema_editor): + PythonFile = apps.get_model("box", "PythonFile") + PythonFile.objects.filter(uploaded_by__isnull=True).update( + status="public", tenant="internal" + ) + PythonFile.objects.filter(uploaded_by__isnull=False).update(status="private") + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("box", "0005_alter_pythonfile_category"), + ] + + operations = [ + migrations.AddField( + model_name="pythonfile", + name="tenant", + field=models.CharField( + choices=[("internal", "Internal"), ("hackathon", "Hackathon")], + db_index=True, + default="internal", + max_length=16, + ), + ), + migrations.AddField( + model_name="pythonfile", + name="status", + field=models.CharField( + choices=[ + ("private", "Private"), + ("submitted", "Submitted"), + ("approved", "Approved"), + ("public", "Public"), + ], + db_index=True, + default="private", + max_length=16, + ), + ), + migrations.AddField( + model_name="pythonfile", + name="submitted_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="pythonfile", + name="reviewed_at", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="pythonfile", + name="review_comment", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="pythonfile", + name="reviewed_by", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="reviewed_nodes", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AlterField( + model_name="pythonfile", + name="file_hash", + field=models.CharField(default="default", max_length=64), + ), + migrations.AddConstraint( + model_name="pythonfile", + constraint=models.UniqueConstraint( + fields=("file_hash", "tenant"), + name="box_pythonfile_hash_tenant_uniq", + ), + ), + migrations.CreateModel( + name="NodeAuditLog", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("action", models.CharField(max_length=32)), + ("from_status", models.CharField(blank=True, default="", max_length=16)), + ("to_status", models.CharField(blank=True, default="", max_length=16)), + ("comment", models.TextField(blank=True, default="")), + ( + "tenant", + models.CharField( + choices=[ + ("internal", "Internal"), + ("hackathon", "Hackathon"), + ], + db_index=True, + default="internal", + max_length=16, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "actor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="node_audit_events", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "python_file", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="audit_logs", + to="box.pythonfile", + ), + ), + ], + options={ + "db_table": "box_nodeauditlog", + "ordering": ["-created_at"], + }, + ), + migrations.RunPython(backfill_node_governance, migrations.RunPython.noop), + ] diff --git a/gui/workflow_backend/django-project/app/box/models.py b/gui/workflow_backend/django-project/app/box/models.py index 706e2af8..b7d6a56a 100644 --- a/gui/workflow_backend/django-project/app/box/models.py +++ b/gui/workflow_backend/django-project/app/box/models.py @@ -6,6 +6,8 @@ import os import logging +from app.tenants import TENANT_CHOICES, TENANT_INTERNAL + logger = logging.getLogger(__name__) @@ -67,6 +69,35 @@ class PythonFile(models.Model): null=True, blank=True, ) + tenant = models.CharField( + max_length=16, + choices=TENANT_CHOICES, + default=TENANT_INTERNAL, + db_index=True, + ) + + class Status(models.TextChoices): + PRIVATE = "private", "Private" + SUBMITTED = "submitted", "Submitted" + APPROVED = "approved", "Approved" + PUBLIC = "public", "Public" + + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.PRIVATE, + db_index=True, + ) + submitted_at = models.DateTimeField(null=True, blank=True) + reviewed_at = models.DateTimeField(null=True, blank=True) + reviewed_by = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="reviewed_nodes", + null=True, + blank=True, + ) + review_comment = models.TextField(blank=True, default="") # Node analysis results node_classes = models.JSONField( @@ -78,7 +109,7 @@ class PythonFile(models.Model): # metadata file_size = models.IntegerField(default=0) # File size (bytes) file_hash = models.CharField( - max_length=64, unique=True, default="default" # Temporary default value + max_length=64, default="default" # Temporary default value ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -87,16 +118,28 @@ class PythonFile(models.Model): class Meta: db_table = "box_pythonfile" ordering = ["-created_at"] + constraints = [ + models.UniqueConstraint( + fields=["file_hash", "tenant"], + name="box_pythonfile_hash_tenant_uniq", + ), + ] def __str__(self): return self.name - def get_node_classes_for_frontend(self): + def get_node_classes_for_frontend(self, user=None): """Returns node class information for the frontend""" if not self.node_classes: return [] frontend_nodes = [] + can_submit = bool( + user + and self.uploaded_by_id + and self.uploaded_by_id == getattr(user, "id", None) + and self.status == self.Status.PRIVATE + ) for class_name, class_info in self.node_classes.items(): # Preserving the original structure and shaping it for the front end frontend_node = { @@ -110,6 +153,9 @@ def get_node_classes_for_frontend(self): "file_name": self.name, # Include all information in the schema "schema": self._convert_to_full_schema(class_info), + "status": self.status, + "tenant": self.tenant, + "can_submit": can_submit, } frontend_nodes.append(frontend_node) @@ -231,3 +277,35 @@ def _map_port_type_to_frontend(self, port_type): "hdf5_file": "hdf5_file", } return type_mapping.get(str(port_type).lower(), "any") + + +class NodeAuditLog(models.Model): + python_file = models.ForeignKey( + PythonFile, on_delete=models.CASCADE, related_name="audit_logs" + ) + actor = models.ForeignKey( + User, + on_delete=models.SET_NULL, + related_name="node_audit_events", + null=True, + blank=True, + ) + action = models.CharField(max_length=32) + from_status = models.CharField(max_length=16, blank=True, default="") + to_status = models.CharField(max_length=16, blank=True, default="") + comment = models.TextField(blank=True, default="") + tenant = models.CharField( + max_length=16, + choices=TENANT_CHOICES, + default=TENANT_INTERNAL, + db_index=True, + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "box_nodeauditlog" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.action} {self.python_file_id} at {self.created_at}" + diff --git a/gui/workflow_backend/django-project/app/box/serializers.py b/gui/workflow_backend/django-project/app/box/serializers.py index aadcf76b..3b9556d1 100644 --- a/gui/workflow_backend/django-project/app/box/serializers.py +++ b/gui/workflow_backend/django-project/app/box/serializers.py @@ -48,6 +48,9 @@ class Meta: "node_classes_count", "created_at", "updated_at", + "tenant", + "status", + "review_comment", ] read_only_fields = [ "id", @@ -55,6 +58,8 @@ class Meta: "file_size", "created_at", "updated_at", + "tenant", + "status", ] def get_node_classes_count(self, obj): diff --git a/gui/workflow_backend/django-project/app/box/services/python_file_service.py b/gui/workflow_backend/django-project/app/box/services/python_file_service.py index ede6b975..ea71cf3e 100644 --- a/gui/workflow_backend/django-project/app/box/services/python_file_service.py +++ b/gui/workflow_backend/django-project/app/box/services/python_file_service.py @@ -13,7 +13,7 @@ class PythonFileService: def __init__(self): self.analyzer = PythonNodeAnalyzer() - def create_python_file(self, file, user=None, name=None, description=None, category='analysis'): + def create_python_file(self, file, user=None, name=None, description=None, category='analysis', tenant=None): """ Create a Python file and run the automated analysis @@ -23,52 +23,54 @@ def create_python_file(self, file, user=None, name=None, description=None, categ name: Filename (optional) description: Description (optional) category: File Category (Optional) + tenant: App tenant (internal | hackathon) Returns: PythonFile instance """ + from app.tenants import TENANT_INTERNAL, get_user_tenant, normalize_tenant + from app.box.models import PythonFile as PF + from app.box.governance import log_node_event + + tenant = normalize_tenant(tenant or (get_user_tenant(user) if user else TENANT_INTERNAL)) + # read file contents file_content = file.read().decode("utf-8") + if hasattr(file, "seek"): + file.seek(0) # Calculate file hash (for duplicate check) file_hash = hashlib.sha256(file_content.encode("utf-8")).hexdigest() - # duplicate check - existing_file = PythonFile.objects.filter(file_hash=file_hash).first() - # overwrite - #if existing_file: - # raise ValueError(f"File already exists: {existing_file.name}") + # duplicate check (per tenant) + existing_file = PythonFile.objects.filter(file_hash=file_hash, tenant=tenant).first() # Decide file name if not name: name = file.name if existing_file is not None: - # Update PythonFile instance - """ - python_file = PythonFile.objects.filter(id=existing_file.id).update( - name=name, - description=description or "", - category=category, - file=file, - file_content=file_content, - uploaded_by=user, - file_size=file.size, - file_hash=file_hash, + same_owner = ( + user is not None + and existing_file.uploaded_by_id is not None + and existing_file.uploaded_by_id == user.id ) - """ - python_file = PythonFile.objects.get(id=existing_file.id) + if not same_owner: + raise ValueError( + "A node with identical content already exists in this tenant." + ) + python_file = existing_file python_file.name = name python_file.description = description or "" python_file.category = category - python_file.file = file python_file.file_content = file_content - python_file.uploaded_by = user python_file.file_size = file.size python_file.file_hash = file_hash python_file.is_active = True + python_file.file = file + # Keep uploaded_by and status — do not take over another user's node + # and do not reset the review pipeline. else: - # Create PythonFile instance python_file = PythonFile.objects.create( name=name, description=description or "", @@ -78,8 +80,18 @@ def create_python_file(self, file, user=None, name=None, description=None, categ uploaded_by=user, file_size=file.size, file_hash=file_hash, + tenant=tenant, + status=PF.Status.PRIVATE if user else PF.Status.PUBLIC, + ) + log_node_event( + python_file, + actor=user, + action="uploaded", + to_status=python_file.status, ) + self._persist_node_file(python_file, file_content) + # Automatic analysis execution self._analyze_file(python_file) @@ -141,7 +153,7 @@ def update_file_content(self, python_file, content): python_file.file_size = len(content.encode("utf-8")) # nodes/{category}/Update physical files in a folder - self._update_nodes_folder_file(python_file, content) + self._persist_node_file(python_file, content) # Djangoのfile Also update the field (preserve existing implementation) if python_file.file: @@ -167,17 +179,38 @@ def update_file_content(self, python_file, content): return python_file + def _persist_node_file(self, python_file, content): + """Write bytes under the tenant nodes root; drop hackathon copies from MEDIA_ROOT.""" + from app.tenants import TENANT_HACKATHON, normalize_tenant + + self._update_nodes_folder_file(python_file, content) + if normalize_tenant(getattr(python_file, "tenant", None)) != TENANT_HACKATHON: + return + if not python_file.file: + return + try: + name = python_file.file.name + if name and default_storage.exists(name): + default_storage.delete(name) + except Exception as e: + logger.warning( + "Failed to drop internal MEDIA_ROOT copy of hackathon node %s: %s", + python_file.name, + e, + ) + def _update_nodes_folder_file(self, python_file, content): """nodes/{category}/Update physical files in a folder""" try: from django.conf import settings from pathlib import Path - + from app.workflow.path_utils import nodes_root + # Convert categories to lowercase category = python_file.category.lower() - + # nodes/{category}/Building a Folder Path - nodes_folder = Path(settings.MEDIA_ROOT) / category + nodes_folder = nodes_root(getattr(python_file, "tenant", None)) / category # If the folder does not exist, create it nodes_folder.mkdir(parents=True, exist_ok=True) diff --git a/gui/workflow_backend/django-project/app/box/urls.py b/gui/workflow_backend/django-project/app/box/urls.py index 2de72650..ba656ea9 100644 --- a/gui/workflow_backend/django-project/app/box/urls.py +++ b/gui/workflow_backend/django-project/app/box/urls.py @@ -8,6 +8,12 @@ PythonFileParameterUpdateView, NodeCategoryListView, BulkSyncNodesView, + NodeSubmitView, + NodeApproveView, + NodePublishView, + NodeRejectView, + NodeReviewQueueView, + NodeAuditLogView, ) app_name = "box" @@ -34,4 +40,10 @@ path("categories/", NodeCategoryListView.as_view(), name="node-categories"), # Bulk node synchronization path("sync/", BulkSyncNodesView.as_view(), name="bulk-sync-nodes"), + path("review-queue/", NodeReviewQueueView.as_view(), name="node-review-queue"), + path("files//submit/", NodeSubmitView.as_view(), name="node-submit"), + path("files//approve/", NodeApproveView.as_view(), name="node-approve"), + path("files//publish/", NodePublishView.as_view(), name="node-publish"), + path("files//reject/", NodeRejectView.as_view(), name="node-reject"), + path("files//audit-log/", NodeAuditLogView.as_view(), name="node-audit-log"), ] diff --git a/gui/workflow_backend/django-project/app/box/views.py b/gui/workflow_backend/django-project/app/box/views.py index fa0851c6..927a0d61 100644 --- a/gui/workflow_backend/django-project/app/box/views.py +++ b/gui/workflow_backend/django-project/app/box/views.py @@ -20,6 +20,15 @@ from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from app.auth.authentication import KeycloakAuthentication +from app.tenants import TENANT_INTERNAL, get_user_tenant, is_node_reviewer +from app.workflow.path_utils import nodes_root +from .governance import ( + approve_node, + publish_node, + reject_node, + submit_node, + visible_python_files, +) logger = logging.getLogger(__name__) @@ -32,9 +41,7 @@ def _can_modify_python_file(user, python_file): def _visible_python_files(user): - return PythonFile.objects.filter(is_active=True).filter( - models.Q(uploaded_by=user) | models.Q(uploaded_by__isnull=True) - ) + return visible_python_files(user) @method_decorator(csrf_exempt, name="dispatch") @@ -60,6 +67,7 @@ def post(self, request): name=serializer.validated_data.get("name"), description=serializer.validated_data.get("description"), category=serializer.validated_data.get("category", "analysis"), + tenant=get_user_tenant(request.user), ) # Serializer for the response @@ -162,14 +170,14 @@ def get(self, request): all_nodes = [] for python_file in python_files: - frontend_nodes = python_file.get_node_classes_for_frontend() + frontend_nodes = python_file.get_node_classes_for_frontend(request.user) all_nodes.extend(frontend_nodes) # Category list node_categories = get_categories() valid_categories = [category[0] for category in node_categories] cat_settings = {} - nodes_path = Path(settings.MEDIA_ROOT) + nodes_path = nodes_root(get_user_tenant(request.user)) for category in valid_categories: category_path = nodes_path / category @@ -203,7 +211,9 @@ def get(self, request): "nodes": all_nodes, "total_files": python_files.count(), "total_nodes": len(all_nodes), - "categories": cat_settings + "categories": cat_settings, + "is_node_reviewer": is_node_reviewer(request.user), + "tenant": get_user_tenant(request.user), } ) @@ -1658,6 +1668,7 @@ def _process_file(self, file_path, category): | models.Q(name=filename, category=category) ) & models.Q(is_active=True) + & models.Q(tenant=TENANT_INTERNAL) ).first() if existing_file: @@ -1701,6 +1712,8 @@ def _process_file(self, file_path, category): file_content=file_content, file_size=file_path.stat().st_size, file_hash=file_hash, + tenant=TENANT_INTERNAL, + status=PythonFile.Status.PUBLIC, # Leave the file field empty (not necessary since file_content is used) ) @@ -1727,3 +1740,124 @@ def _process_file(self, file_path, category): "category": category, "error": str(e), } + + +class NodeSubmitView(APIView): + authentication_classes = [KeycloakAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + python_file = get_object_or_404(_visible_python_files(request.user), pk=pk) + try: + submit_node(python_file, request.user) + except Exception as e: + from rest_framework.exceptions import PermissionDenied, ValidationError + + if isinstance(e, PermissionDenied): + return Response({"error": str(e)}, status=status.HTTP_403_FORBIDDEN) + if isinstance(e, ValidationError): + return Response({"error": str(e.detail if hasattr(e, "detail") else e)}, status=status.HTTP_400_BAD_REQUEST) + raise + return Response(PythonFileSerializer(python_file, context={"request": request}).data) + + +class NodeApproveView(APIView): + authentication_classes = [KeycloakAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + python_file = get_object_or_404(_visible_python_files(request.user), pk=pk) + make_public = bool((request.data or {}).get("make_public")) + comment = (request.data or {}).get("comment") or "" + try: + approve_node(python_file, request.user, make_public=make_public, comment=comment) + except Exception as e: + from rest_framework.exceptions import PermissionDenied, ValidationError + + if isinstance(e, PermissionDenied): + return Response({"error": str(e)}, status=status.HTTP_403_FORBIDDEN) + if isinstance(e, ValidationError): + return Response({"error": str(e.detail if hasattr(e, "detail") else e)}, status=status.HTTP_400_BAD_REQUEST) + raise + return Response(PythonFileSerializer(python_file, context={"request": request}).data) + + +class NodePublishView(APIView): + authentication_classes = [KeycloakAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + python_file = get_object_or_404(_visible_python_files(request.user), pk=pk) + comment = (request.data or {}).get("comment") or "" + try: + publish_node(python_file, request.user, comment=comment) + except Exception as e: + from rest_framework.exceptions import PermissionDenied, ValidationError + + if isinstance(e, PermissionDenied): + return Response({"error": str(e)}, status=status.HTTP_403_FORBIDDEN) + if isinstance(e, ValidationError): + return Response({"error": str(e.detail if hasattr(e, "detail") else e)}, status=status.HTTP_400_BAD_REQUEST) + raise + return Response(PythonFileSerializer(python_file, context={"request": request}).data) + + +class NodeRejectView(APIView): + authentication_classes = [KeycloakAuthentication] + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + python_file = get_object_or_404(_visible_python_files(request.user), pk=pk) + comment = (request.data or {}).get("comment") or "" + try: + reject_node(python_file, request.user, comment=comment) + except Exception as e: + from rest_framework.exceptions import PermissionDenied, ValidationError + + if isinstance(e, PermissionDenied): + return Response({"error": str(e)}, status=status.HTTP_403_FORBIDDEN) + if isinstance(e, ValidationError): + return Response({"error": str(e.detail if hasattr(e, "detail") else e)}, status=status.HTTP_400_BAD_REQUEST) + raise + return Response(PythonFileSerializer(python_file, context={"request": request}).data) + + +class NodeReviewQueueView(APIView): + authentication_classes = [KeycloakAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + if not is_node_reviewer(request.user): + return Response({"error": "Node reviewers only."}, status=status.HTTP_403_FORBIDDEN) + qs = PythonFile.objects.filter( + is_active=True, + tenant=get_user_tenant(request.user), + status=PythonFile.Status.SUBMITTED, + ) + serializer = PythonFileSerializer(qs, many=True, context={"request": request}) + return Response({"nodes": serializer.data, "count": qs.count()}) + + +class NodeAuditLogView(APIView): + authentication_classes = [KeycloakAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request, pk): + python_file = get_object_or_404(_visible_python_files(request.user), pk=pk) + logs = python_file.audit_logs.all()[:100] + return Response( + { + "logs": [ + { + "id": log.id, + "action": log.action, + "from_status": log.from_status, + "to_status": log.to_status, + "comment": log.comment, + "actor": log.actor.username if log.actor_id else None, + "created_at": log.created_at.isoformat(), + } + for log in logs + ] + } + ) diff --git a/gui/workflow_backend/django-project/app/tenants.py b/gui/workflow_backend/django-project/app/tenants.py new file mode 100644 index 00000000..b99952e2 --- /dev/null +++ b/gui/workflow_backend/django-project/app/tenants.py @@ -0,0 +1,145 @@ +"""App tenants: internal vs hackathon. + +Keycloak groups ``nw-internal`` / ``nw-hackathon`` are synced onto Django +``Group`` membership at login. Existing users with no group are treated as +internal (and assigned that group on first login with no tenant claim). +""" + +from __future__ import annotations + +from django.contrib.auth.models import Group + +TENANT_INTERNAL = "internal" +TENANT_HACKATHON = "hackathon" +TENANT_CHOICES = ( + (TENANT_INTERNAL, "Internal"), + (TENANT_HACKATHON, "Hackathon"), +) + +GROUP_INTERNAL = "nw-internal" +GROUP_HACKATHON = "nw-hackathon" +GROUP_NODE_REVIEWERS = "node-reviewers" + +TENANT_GROUPS = (GROUP_INTERNAL, GROUP_HACKATHON) + +HUB_USER_INTERNAL = "internal" +HUB_USER_HACKATHON = "hackathon" +HUB_USER_LEGACY = "user1" + +JUPYTER_HONESTY_NOTICE = ( + "Jupyter file browser hides other private projects in this space. " + "The kernel and terminal can still see every path mounted in this Lab. " + "Isolation between internal and hackathon spaces is filesystem-level." +) + + +def normalize_tenant(value: str | None) -> str: + if (value or "").strip().lower() == TENANT_HACKATHON: + return TENANT_HACKATHON + return TENANT_INTERNAL + + +def hub_username_for_tenant(tenant: str | None) -> str: + if normalize_tenant(tenant) == TENANT_HACKATHON: + return HUB_USER_HACKATHON + return HUB_USER_INTERNAL + + +def ensure_tenant_groups() -> dict[str, Group]: + names = (GROUP_INTERNAL, GROUP_HACKATHON, GROUP_NODE_REVIEWERS) + return {name: Group.objects.get_or_create(name=name)[0] for name in names} + + +def get_user_tenant(user) -> str: + if user is None or not getattr(user, "is_authenticated", False): + return TENANT_INTERNAL + names = set(user.groups.values_list("name", flat=True)) + if GROUP_INTERNAL in names: + return TENANT_INTERNAL + if GROUP_HACKATHON in names: + return TENANT_HACKATHON + return TENANT_INTERNAL + + +def set_user_tenant(user, tenant: str) -> str: + tenant = normalize_tenant(tenant) + names = set(user.groups.values_list("name", flat=True)) + if tenant == TENANT_HACKATHON: + already = GROUP_HACKATHON in names and GROUP_INTERNAL not in names + else: + already = GROUP_INTERNAL in names and GROUP_HACKATHON not in names + if already: + return tenant + groups = ensure_tenant_groups() + if tenant == TENANT_HACKATHON: + user.groups.remove(groups[GROUP_INTERNAL]) + user.groups.add(groups[GROUP_HACKATHON]) + else: + user.groups.remove(groups[GROUP_HACKATHON]) + user.groups.add(groups[GROUP_INTERNAL]) + return tenant + + +def is_node_reviewer(user) -> bool: + if user is None or not getattr(user, "is_authenticated", False): + return False + if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): + return True + return user.groups.filter(name=GROUP_NODE_REVIEWERS).exists() + + +def same_tenant(user, obj) -> bool: + obj_tenant = getattr(obj, "tenant", None) + if obj_tenant is None: + return True + return normalize_tenant(obj_tenant) == get_user_tenant(user) + + +def _claim_strings(payload: dict) -> list[str]: + values: list[str] = [] + groups = payload.get("groups") or [] + if isinstance(groups, str): + groups = [groups] + values.extend(str(g) for g in groups) + realm = payload.get("realm_access") or {} + roles = realm.get("roles") or [] + if isinstance(roles, str): + roles = [roles] + values.extend(str(r) for r in roles) + return values + + +def _claim_name_set(payload: dict) -> set[str]: + """Exact group/role names (last path segment), not substring matches.""" + names: set[str] = set() + for raw in _claim_strings(payload): + text = str(raw).strip().strip("/") + if not text: + continue + names.add(text.lower()) + names.add(text.rsplit("/", 1)[-1].lower()) + return names + + +def tenant_from_claims(payload: dict | None) -> str | None: + """Return a tenant if the token names one; otherwise None (leave as-is).""" + if not payload: + return None + names = _claim_name_set(payload) + has_internal = GROUP_INTERNAL.lower() in names + has_hackathon = GROUP_HACKATHON.lower() in names + if has_internal: + return TENANT_INTERNAL + if has_hackathon: + return TENANT_HACKATHON + return None + + +def sync_user_tenant_from_payload(user, payload: dict | None) -> str: + claimed = tenant_from_claims(payload) + if claimed: + return set_user_tenant(user, claimed) + names = set(user.groups.values_list("name", flat=True)) + if GROUP_INTERNAL not in names and GROUP_HACKATHON not in names: + return set_user_tenant(user, TENANT_INTERNAL) + return get_user_tenant(user) diff --git a/gui/workflow_backend/django-project/app/workflow/execution/local_executor.py b/gui/workflow_backend/django-project/app/workflow/execution/local_executor.py index ce945967..a91ae72e 100644 --- a/gui/workflow_backend/django-project/app/workflow/execution/local_executor.py +++ b/gui/workflow_backend/django-project/app/workflow/execution/local_executor.py @@ -11,6 +11,8 @@ from django.conf import settings +from app.workflow.models import FlowProject +from app.workflow.path_utils import existing_project_dir from .base import ExecutionBackend, ExecutionResult, ExecutionStatus logger = logging.getLogger(__name__) @@ -23,8 +25,7 @@ class LocalExecutor(ExecutionBackend): """Run workflow Python scripts as subprocesses on the same host.""" def __init__(self): - self.code_dir = Path(settings.BASE_DIR) / "codes" / "projects" - self.code_dir.mkdir(parents=True, exist_ok=True) + pass def submit( self, @@ -41,7 +42,12 @@ def submit( ) if run_id: result.run_id = run_id - project_dir = self.code_dir / str(workflow_id) + try: + project = FlowProject.objects.get(id=workflow_id) + project_dir = existing_project_dir(project, create=True) + except FlowProject.DoesNotExist: + project_dir = Path(settings.BASE_DIR) / "codes" / "projects" / str(workflow_id) + project_dir.mkdir(parents=True, exist_ok=True) script_path = project_dir / "workflow.py" if not script_path.exists(): diff --git a/gui/workflow_backend/django-project/app/workflow/execution/remote_slurm_executor.py b/gui/workflow_backend/django-project/app/workflow/execution/remote_slurm_executor.py index ac1c5601..646b6984 100644 --- a/gui/workflow_backend/django-project/app/workflow/execution/remote_slurm_executor.py +++ b/gui/workflow_backend/django-project/app/workflow/execution/remote_slurm_executor.py @@ -22,7 +22,7 @@ from django.conf import settings -from app.workflow.path_utils import batch_run_dir, projects_root +from app.workflow.path_utils import batch_run_dir, existing_project_dir, nodes_root from .base import ExecutionBackend, ExecutionResult, ExecutionStatus @@ -236,8 +236,17 @@ def submit( # also resolve on the compute node. Exclude ``batch/`` (this staging # tree itself) and ``results/`` (stale local outputs); the generated # workflow.py/run.sbatch are written afterwards so they always win. - project_dir = projects_root() / str(workflow_id) - if project_dir.is_dir(): + from app.workflow.models import FlowProject + + try: + project = FlowProject.objects.get(id=workflow_id) + except FlowProject.DoesNotExist: + project = None + project_dir = ( + existing_project_dir(project) if project is not None else None + ) + + if project_dir is not None and project_dir.is_dir(): shutil.copytree( project_dir, local_dir, @@ -251,7 +260,8 @@ def submit( # (which does ``from nodes.. import ...``) can import it on # the compute node. When ``python workflow.py`` runs in the run dir, # sys.path[0] is that dir, so ``/nodes`` resolves. - nodes_src = Path(settings.MEDIA_ROOT) + tenant = getattr(project, "tenant", None) if project is not None else None + nodes_src = nodes_root(tenant) if nodes_src.is_dir(): shutil.copytree( nodes_src, diff --git a/gui/workflow_backend/django-project/app/workflow/jupyter_auth.py b/gui/workflow_backend/django-project/app/workflow/jupyter_auth.py new file mode 100644 index 00000000..5fbd9d92 --- /dev/null +++ b/gui/workflow_backend/django-project/app/workflow/jupyter_auth.py @@ -0,0 +1,31 @@ +"""Authenticate Jupyter contents-filter calls with a signed viewer token.""" + +from rest_framework import authentication, exceptions + +from .viewer_tokens import ViewerTokenError, user_from_viewer_token + + +class JupyterViewerTokenAuthentication(authentication.BaseAuthentication): + def authenticate(self, request): + token = _extract_viewer_token(request) + if not token: + return None + try: + user, payload = user_from_viewer_token(token) + except ViewerTokenError as exc: + raise exceptions.AuthenticationFailed(str(exc)) from exc + request.viewer_payload = payload + return (user, token) + + def authenticate_header(self, request): + return 'Viewer realm="jupyter"' + + +def _extract_viewer_token(request) -> str | None: + header = authentication.get_authorization_header(request).decode("utf-8") + if header.lower().startswith("viewer "): + return header.split(" ", 1)[1].strip() or None + token = request.META.get("HTTP_X_NW_VIEWER_TOKEN") or request.GET.get("token") + if token: + return token + return None diff --git a/gui/workflow_backend/django-project/app/workflow/jupyter_execution_service.py b/gui/workflow_backend/django-project/app/workflow/jupyter_execution_service.py index 6dfc2e3a..3ec7e460 100644 --- a/gui/workflow_backend/django-project/app/workflow/jupyter_execution_service.py +++ b/gui/workflow_backend/django-project/app/workflow/jupyter_execution_service.py @@ -25,7 +25,9 @@ else JUPYTERHUB_INTERNAL_HOST ) JUPYTERHUB_API_TOKEN = os.environ.get("JUPYTERHUB_API_TOKEN") or None -JUPYTER_USER = os.environ.get("JUPYTER_EXECUTION_USER", "user1") +JUPYTER_USER = os.environ.get("JUPYTER_EXECUTION_USER", "internal") +if JUPYTER_USER == "user1": + JUPYTER_USER = "internal" # Timeouts SERVER_START_TIMEOUT = 120 # seconds to wait for server to start diff --git a/gui/workflow_backend/django-project/app/workflow/jupyter_views.py b/gui/workflow_backend/django-project/app/workflow/jupyter_views.py new file mode 100644 index 00000000..334dd678 --- /dev/null +++ b/gui/workflow_backend/django-project/app/workflow/jupyter_views.py @@ -0,0 +1,84 @@ +"""Jupyter session + visible-path APIs used by the Lab contents filter.""" + +from __future__ import annotations + +from django.db.models import Q +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from app.auth.authentication import KeycloakAuthentication +from app.tenants import ( + JUPYTER_HONESTY_NOTICE, + get_user_tenant, + hub_username_for_tenant, + is_node_reviewer, +) +from app.workflow.models import FlowProject +from app.workflow.path_utils import legacy_project_dir +from app.workflow.viewer_tokens import mint_viewer_token + +from .jupyter_auth import JupyterViewerTokenAuthentication + + +def visible_projects_for_user(user): + tenant = get_user_tenant(user) + return FlowProject.objects.filter(is_active=True).filter( + Q(owner=user) + | (Q(tenant=tenant) & Q(visibility=FlowProject.Visibility.PUBLIC)) + ) + + +def visible_paths_payload(user) -> dict: + tenant = get_user_tenant(user) + projects = list(visible_projects_for_user(user).only("id", "name", "visibility")) + project_ids = [str(p.id) for p in projects] + legacy_names = [] + for project in projects: + legacy_names.append(legacy_project_dir(project).name) + name = (project.name or "").replace(" ", "") + if name: + legacy_names.append(name) + return { + "tenant": tenant, + "hub_user": hub_username_for_tenant(tenant), + "project_ids": project_ids, + "legacy_names": sorted(set(legacy_names)), + "hide_unlisted_projects": True, + "notice": JUPYTER_HONESTY_NOTICE, + } + + +class JupyterSessionView(APIView): + """Mint a viewer token and tell the GUI which Hub user to open.""" + + authentication_classes = [KeycloakAuthentication] + permission_classes = [IsAuthenticated] + + def get(self, request): + tenant = get_user_tenant(request.user) + hub_user = hub_username_for_tenant(tenant) + token = mint_viewer_token(request.user, tenant=tenant) + return Response( + { + "tenant": tenant, + "hub_user": hub_user, + "jupyter_path": f"/user/{hub_user}/", + "viewer_token": token, + "is_node_reviewer": is_node_reviewer(request.user), + "notice": JUPYTER_HONESTY_NOTICE, + } + ) + + +class JupyterVisiblePathsView(APIView): + """Allow-list of project dirs the Lab file browser may show.""" + + authentication_classes = [ + JupyterViewerTokenAuthentication, + KeycloakAuthentication, + ] + permission_classes = [IsAuthenticated] + + def get(self, request): + return Response(visible_paths_payload(request.user)) diff --git a/gui/workflow_backend/django-project/app/workflow/migrations/0005_flowproject_tenant.py b/gui/workflow_backend/django-project/app/workflow/migrations/0005_flowproject_tenant.py new file mode 100644 index 00000000..4908074f --- /dev/null +++ b/gui/workflow_backend/django-project/app/workflow/migrations/0005_flowproject_tenant.py @@ -0,0 +1,28 @@ +from django.db import migrations, models + + +def ensure_groups(apps, schema_editor): + Group = apps.get_model("auth", "Group") + for name in ("nw-internal", "nw-hackathon", "node-reviewers"): + Group.objects.get_or_create(name=name) + + +class Migration(migrations.Migration): + + dependencies = [ + ("workflow", "0004_flowproject_attribution"), + ] + + operations = [ + migrations.AddField( + model_name="flowproject", + name="tenant", + field=models.CharField( + choices=[("internal", "Internal"), ("hackathon", "Hackathon")], + db_index=True, + default="internal", + max_length=16, + ), + ), + migrations.RunPython(ensure_groups, migrations.RunPython.noop), + ] diff --git a/gui/workflow_backend/django-project/app/workflow/models.py b/gui/workflow_backend/django-project/app/workflow/models.py index 50b9deab..fa7ab4e8 100644 --- a/gui/workflow_backend/django-project/app/workflow/models.py +++ b/gui/workflow_backend/django-project/app/workflow/models.py @@ -2,6 +2,8 @@ from django.contrib.auth.models import User import uuid +from app.tenants import TENANT_CHOICES, TENANT_INTERNAL + def _default_workflow_context(): return { @@ -31,6 +33,12 @@ class HpcTarget(models.TextChoices): default=Visibility.PRIVATE, db_index=True, ) + tenant = models.CharField( + max_length=16, + choices=TENANT_CHOICES, + default=TENANT_INTERNAL, + db_index=True, + ) reference = models.TextField(blank=True, default="") hpc_target = models.CharField( max_length=32, diff --git a/gui/workflow_backend/django-project/app/workflow/path_utils.py b/gui/workflow_backend/django-project/app/workflow/path_utils.py index 96fc12dc..bf777d36 100644 --- a/gui/workflow_backend/django-project/app/workflow/path_utils.py +++ b/gui/workflow_backend/django-project/app/workflow/path_utils.py @@ -85,14 +85,40 @@ def is_allowed_upload_filename(filename: str) -> bool: return suffix in ALLOWED_UPLOAD_SUFFIXES or suffix in ALLOWED_UPLOAD_COMPOUND_SUFFIXES -def projects_root() -> Path: - root = Path(settings.BASE_DIR) / "codes" / "projects" +def projects_root(tenant: str | None = None) -> Path: + from app.tenants import TENANT_HACKATHON, normalize_tenant + + tenant = normalize_tenant(tenant) + if tenant == TENANT_HACKATHON: + root = Path(settings.BASE_DIR) / "codes-hackathon" / "projects" + else: + root = Path(settings.BASE_DIR) / "codes" / "projects" root.mkdir(parents=True, exist_ok=True) return root -def _ensure_under_root(path: Path) -> Path: - root = projects_root().resolve() +def nodes_root(tenant: str | None = None) -> Path: + from app.tenants import TENANT_HACKATHON, normalize_tenant + + tenant = normalize_tenant(tenant) + if tenant == TENANT_HACKATHON: + root = Path(settings.BASE_DIR) / "codes-hackathon" / "nodes" + else: + root = Path(getattr(settings, "MEDIA_ROOT", Path(settings.BASE_DIR) / "codes" / "nodes")) + root.mkdir(parents=True, exist_ok=True) + return root + + +def _tenant_of(project=None, tenant=None) -> str | None: + if tenant: + return tenant + if project is not None: + return getattr(project, "tenant", None) + return None + + +def _ensure_under_root(path: Path, *, project=None, tenant=None) -> Path: + root = projects_root(_tenant_of(project, tenant)).resolve() resolved = path.resolve(strict=False) if root != resolved and root not in resolved.parents: raise ValueError("Resolved path escapes the workflow projects directory") @@ -100,13 +126,15 @@ def _ensure_under_root(path: Path) -> Path: def stable_project_dir(project, *, create: bool = False) -> Path: - path = _ensure_under_root(projects_root() / str(project.id)) + path = _ensure_under_root( + projects_root(_tenant_of(project)) / str(project.id), project=project + ) if create: path.mkdir(parents=True, exist_ok=True) return path -def batch_run_dir(project_id, run_id, *, create: bool = False) -> Path: +def batch_run_dir(project_id, run_id, *, create: bool = False, project=None) -> Path: """Per-run working dir for cluster (batch) executions, co-located with the project so Jupyter and cluster runs share ``codes/projects//``. @@ -114,8 +142,18 @@ def batch_run_dir(project_id, run_id, *, create: bool = False) -> Path: inputs (workflow.py, run.sbatch, nodes/) and a ``results/`` subdir for the artifacts fetched back from the compute server. """ + if project is None: + from .models import FlowProject + + try: + project = FlowProject.objects.get(id=project_id) + except Exception: + project = None + tenant = _tenant_of(project) path = _ensure_under_root( - projects_root() / str(project_id) / "batch" / str(run_id) + projects_root(tenant) / str(project_id) / "batch" / str(run_id), + project=project, + tenant=tenant, ) if create: path.mkdir(parents=True, exist_ok=True) @@ -125,7 +163,9 @@ def batch_run_dir(project_id, run_id, *, create: bool = False) -> Path: def legacy_project_dir(project) -> Path: legacy_name = (project.name or str(project.id)).replace(" ", "").capitalize() legacy_name = re.sub(r"[^A-Za-z0-9_.-]", "_", legacy_name) or str(project.id) - return _ensure_under_root(projects_root() / legacy_name) + return _ensure_under_root( + projects_root(_tenant_of(project)) / legacy_name, project=project + ) def existing_project_dir(project, *, create: bool = False) -> Path: @@ -145,15 +185,15 @@ def existing_project_dir(project, *, create: bool = False) -> Path: def code_file_path(project, *, create: bool = False) -> Path: project_dir = stable_project_dir(project, create=create) if create else existing_project_dir(project) if project_dir.name == str(project.id): - return _ensure_under_root(project_dir / WORKFLOW_CODE_FILENAME) - return _ensure_under_root(project_dir / f"{project_dir.name}.py") + return _ensure_under_root(project_dir / WORKFLOW_CODE_FILENAME, project=project) + return _ensure_under_root(project_dir / f"{project_dir.name}.py", project=project) def notebook_file_path(project, *, create: bool = False) -> Path: project_dir = stable_project_dir(project, create=create) if create else existing_project_dir(project) if project_dir.name == str(project.id): - return _ensure_under_root(project_dir / WORKFLOW_NOTEBOOK_FILENAME) - return _ensure_under_root(project_dir / f"{project_dir.name}.ipynb") + return _ensure_under_root(project_dir / WORKFLOW_NOTEBOOK_FILENAME, project=project) + return _ensure_under_root(project_dir / f"{project_dir.name}.ipynb", project=project) def safe_report_path(project, filename: str, *, create_dir: bool = False) -> Path: @@ -170,7 +210,7 @@ def safe_report_path(project, filename: str, *, create_dir: bool = False) -> Pat raise ValueError("Invalid report filename") project_dir = stable_project_dir(project, create=create_dir) if create_dir else existing_project_dir(project) - return _ensure_under_root(project_dir / candidate.name) + return _ensure_under_root(project_dir / candidate.name, project=project) def safe_project_upload_path(project, filename: str, *, create_dir: bool = True) -> Path: @@ -204,4 +244,4 @@ def safe_project_upload_path(project, filename: str, *, create_dir: bool = True) if create_dir else existing_project_dir(project, create=False) ) - return _ensure_under_root(project_dir / candidate.name) + return _ensure_under_root(project_dir / candidate.name, project=project) diff --git a/gui/workflow_backend/django-project/app/workflow/permissions.py b/gui/workflow_backend/django-project/app/workflow/permissions.py index e04ef834..5c8492d8 100644 --- a/gui/workflow_backend/django-project/app/workflow/permissions.py +++ b/gui/workflow_backend/django-project/app/workflow/permissions.py @@ -1,13 +1,17 @@ -"""Permission rules for FlowProject visibility. +"""Permission rules for FlowProject visibility and tenant isolation. Current product rule: +- Projects are visible only inside the caller's tenant. - Private projects are visible/editable only to the owner. -- Public projects are visible and editable by any authenticated user. +- Public projects are visible and editable by any authenticated user in the + same tenant. - DELETE and visibility changes are owner-only, even for public projects. """ from rest_framework import exceptions, permissions +from app.tenants import get_user_tenant, same_tenant + from .models import FlowProject @@ -19,7 +23,7 @@ def _is_visibility_change(request) -> bool: class IsAuthenticatedAndProjectVisible(permissions.BasePermission): - """Allow access when the user owns the project or it is public.""" + """Allow access when the user owns the project or it is public in-tenant.""" def has_permission(self, request, view): return bool(request.user and request.user.is_authenticated) @@ -30,6 +34,8 @@ def has_object_permission(self, request, view, obj): project = getattr(obj, "workflow", None) if project is None: return False + if not same_tenant(request.user, project): + return False if project.owner_id == request.user.id: return True return project.visibility == FlowProject.Visibility.PUBLIC @@ -39,7 +45,7 @@ class IsOwnerForDestructive(permissions.BasePermission): """DELETE and visibility changes require ownership. Non-destructive writes on public projects are intentionally allowed for - authenticated non-owners. + authenticated non-owners in the same tenant. """ def has_permission(self, request, view): @@ -51,6 +57,8 @@ def has_object_permission(self, request, view, obj): project = getattr(obj, "workflow", None) if project is None: return False + if not same_tenant(request.user, project): + return False if request.method == "DELETE": return project.owner_id == request.user.id if _is_visibility_change(request): @@ -78,6 +86,9 @@ def get_accessible_project(request, project_id, *, write: bool = False) -> FlowP except FlowProject.DoesNotExist: raise exceptions.NotFound("Project not found.") + if not same_tenant(request.user, project): + raise exceptions.NotFound("Project not found.") + is_owner = project.owner_id == request.user.id is_public = project.visibility == FlowProject.Visibility.PUBLIC if not (is_owner or is_public): @@ -87,3 +98,16 @@ def get_accessible_project(request, project_id, *, write: bool = False) -> FlowP raise exceptions.PermissionDenied("Not allowed to modify this project.") return project + + +def tenant_queryset(user): + """Active projects the user may list in their tenant.""" + from django.db.models import Q + + return FlowProject.objects.filter(is_active=True).filter( + Q(owner=user) + | ( + Q(tenant=get_user_tenant(user)) + & Q(visibility=FlowProject.Visibility.PUBLIC) + ) + ) diff --git a/gui/workflow_backend/django-project/app/workflow/serializers.py b/gui/workflow_backend/django-project/app/workflow/serializers.py index 397c6fc7..d90e2ecf 100644 --- a/gui/workflow_backend/django-project/app/workflow/serializers.py +++ b/gui/workflow_backend/django-project/app/workflow/serializers.py @@ -34,6 +34,7 @@ class Meta: "workflow_context", "owner", "visibility", + "tenant", "reference", "hpc_target", "doi", @@ -59,6 +60,7 @@ class Meta: "updated_at", "owner", "is_active", + "tenant", "is_owned_by_me", "can_edit", "can_delete", diff --git a/gui/workflow_backend/django-project/app/workflow/urls.py b/gui/workflow_backend/django-project/app/workflow/urls.py index 012e533e..73143197 100644 --- a/gui/workflow_backend/django-project/app/workflow/urls.py +++ b/gui/workflow_backend/django-project/app/workflow/urls.py @@ -1,4 +1,5 @@ from django.urls import path +from .jupyter_views import JupyterSessionView, JupyterVisiblePathsView from .views import ( FlowProjectViewSet, FlowNodeViewSet, @@ -45,6 +46,12 @@ urlpatterns = [ + path("jupyter/session/", JupyterSessionView.as_view(), name="jupyter-session"), + path( + "jupyter/visible-paths/", + JupyterVisiblePathsView.as_view(), + name="jupyter-visible-paths", + ), # project management path("", project_list, name="workflow-list-create"), # GET(list), POST(create) path( diff --git a/gui/workflow_backend/django-project/app/workflow/viewer_tokens.py b/gui/workflow_backend/django-project/app/workflow/viewer_tokens.py new file mode 100644 index 00000000..7c36c4d4 --- /dev/null +++ b/gui/workflow_backend/django-project/app/workflow/viewer_tokens.py @@ -0,0 +1,54 @@ +"""Short-lived signed tokens that identify the Keycloak user to a shared Lab.""" + +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.core import signing + +from app.tenants import ( + get_user_tenant, + hub_username_for_tenant, + normalize_tenant, +) + +VIEWER_SALT = "nw-jupyter-viewer" +VIEWER_MAX_AGE_SECONDS = 8 * 60 * 60 + + +class ViewerTokenError(Exception): + pass + + +def mint_viewer_token(user, *, tenant: str | None = None) -> str: + tenant = normalize_tenant(tenant or get_user_tenant(user)) + signer = signing.TimestampSigner(salt=VIEWER_SALT) + return signer.sign_object( + { + "uid": user.id, + "tenant": tenant, + "hub": hub_username_for_tenant(tenant), + } + ) + + +def unsign_viewer_token(token: str, *, max_age: int = VIEWER_MAX_AGE_SECONDS) -> dict: + signer = signing.TimestampSigner(salt=VIEWER_SALT) + try: + payload = signer.unsign_object(token, max_age=max_age) + except signing.BadSignature as exc: + raise ViewerTokenError("Invalid or expired viewer token") from exc + if not isinstance(payload, dict) or "uid" not in payload: + raise ViewerTokenError("Invalid viewer token payload") + payload["tenant"] = normalize_tenant(payload.get("tenant")) + payload["hub"] = payload.get("hub") or hub_username_for_tenant(payload["tenant"]) + return payload + + +def user_from_viewer_token(token: str): + payload = unsign_viewer_token(token) + User = get_user_model() + try: + user = User.objects.get(pk=payload["uid"]) + except User.DoesNotExist as exc: + raise ViewerTokenError("Viewer token user no longer exists") from exc + return user, payload diff --git a/gui/workflow_backend/django-project/app/workflow/views.py b/gui/workflow_backend/django-project/app/workflow/views.py index 67740541..778581ab 100644 --- a/gui/workflow_backend/django-project/app/workflow/views.py +++ b/gui/workflow_backend/django-project/app/workflow/views.py @@ -26,6 +26,7 @@ from rest_framework.views import APIView from app.auth.authentication import KeycloakAuthentication +from app.tenants import get_user_tenant, hub_username_for_tenant from .code_generation_service import CodeGenerationService from .jupyter_execution_service import JupyterExecutionService @@ -73,13 +74,15 @@ class FlowProjectViewSet(viewsets.ModelViewSet): lookup_url_kwarg = "workflow_id" def get_queryset(self): - user = self.request.user - return FlowProject.objects.filter(is_active=True).filter( - Q(owner=user) | Q(visibility=FlowProject.Visibility.PUBLIC) - ) + from .permissions import tenant_queryset + + return tenant_queryset(self.request.user) def perform_create(self, serializer): - return serializer.save(owner=self.request.user) + return serializer.save( + owner=self.request.user, + tenant=get_user_tenant(self.request.user), + ) def create_project_python_file(self, project): """Generate Python files when creating a project""" @@ -148,8 +151,8 @@ def get_queryset(self): return FlowNode.objects.none() user = self.request.user return FlowNode.objects.filter(project_id=project_id).filter( - Q(project__owner=user) - | Q(project__visibility=FlowProject.Visibility.PUBLIC) + Q(project__tenant=get_user_tenant(user)) + & (Q(project__owner=user) | Q(project__visibility=FlowProject.Visibility.PUBLIC)) ) def initial(self, request, *args, **kwargs): @@ -383,8 +386,8 @@ def get_queryset(self): return FlowEdge.objects.none() user = self.request.user return FlowEdge.objects.filter(project_id=project_id).filter( - Q(project__owner=user) - | Q(project__visibility=FlowProject.Visibility.PUBLIC) + Q(project__tenant=get_user_tenant(user)) + & (Q(project__owner=user) | Q(project__visibility=FlowProject.Visibility.PUBLIC)) ) def initial(self, request, *args, **kwargs): @@ -548,18 +551,18 @@ def get(self, request, workflow_id): """Return the JupyterLab URL""" try: project = get_accessible_project(request, workflow_id, write=False) - - # JupyterLab URL generation - #jupyter_url = f"http://localhost:8000/user/user1/lab/tree/codes/projects/{workflow_id}" - jupyter_url = f"http://localhost:8000/user/user1/lab/tree/codes/projects/" - #jupyter_url = f"http://localhost:8000/user/user1/lab/workspaces/auto-E/tree/codes/nodes/{workflow_id}/{workflow_id}.py" - - + hub_user = hub_username_for_tenant(get_user_tenant(request.user)) + jupyter_url = ( + f"/jupyter/user/{hub_user}/lab/tree/codes/projects/{project.id}/" + ) + return JsonResponse({ "status": "success", "jupyter_url": jupyter_url, "workflow_id": str(workflow_id), - "project_name": project.name + "project_name": project.name, + "hub_user": hub_user, + "tenant": project.tenant, }) except Exception as e: @@ -916,7 +919,9 @@ def _sync_event_generator(self, workflow_id, project_name, code): "project_name": project_name, }) - service = JupyterExecutionService() + service = JupyterExecutionService( + user=hub_username_for_tenant(get_user_tenant(self.request.user)) + ) agen = service.execute_code(code) while True: diff --git a/gui/workflow_backend/django-project/codes-hackathon/README.md b/gui/workflow_backend/django-project/codes-hackathon/README.md new file mode 100644 index 00000000..34943755 --- /dev/null +++ b/gui/workflow_backend/django-project/codes-hackathon/README.md @@ -0,0 +1,18 @@ +# Hackathon Jupyter tree + +This directory is the **hackathon** Lab filesystem (Hub user `hackathon`). +It is mounted instead of `codes/` for that Lab. + +``` +codes-hackathon/ + projects/ # hackathon FlowProject dirs (UUID) + nodes/ # tenant-scoped node files (not the internal catalog) +``` + +The neuroworkflow Python library is still mounted read-only from +`codes/neuroworkflow` into the hackathon container. + +Do **not** copy the internal `codes/nodes` catalog here automatically. +Public hackathon nodes are an explicit allow-list / copy at cutover. + +See `deployment/JUPYTER_TWO_SPACES.md`. diff --git a/gui/workflow_backend/django-project/codes-hackathon/nodes/.gitkeep b/gui/workflow_backend/django-project/codes-hackathon/nodes/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/gui/workflow_backend/django-project/codes-hackathon/projects/.gitkeep b/gui/workflow_backend/django-project/codes-hackathon/projects/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/gui/workflow_backend/django-project/neuroworkflow/PRIVACY_NOTICE.md b/gui/workflow_backend/django-project/neuroworkflow/PRIVACY_NOTICE.md new file mode 100644 index 00000000..953cf571 --- /dev/null +++ b/gui/workflow_backend/django-project/neuroworkflow/PRIVACY_NOTICE.md @@ -0,0 +1,10 @@ +# Jupyter privacy notice (this Lab) + +The file browser hides other people's **private** project folders in this space. + +That hide is **visual only**. The kernel and terminal can still see every path +that is mounted in this Lab. Do not treat JupyterLab as a security boundary +inside your group (internal or hackathon). + +Isolation **between** the internal Lab and the hackathon Lab is real: those +are separate containers with separate host directories. diff --git a/gui/workflow_backend/django-project/neuroworkflow/jupyter_server_config.py b/gui/workflow_backend/django-project/neuroworkflow/jupyter_server_config.py new file mode 100644 index 00000000..a7f35243 --- /dev/null +++ b/gui/workflow_backend/django-project/neuroworkflow/jupyter_server_config.py @@ -0,0 +1,5 @@ +# Jupyter server config mounted into both Labs. +# Enables the NeuroWorkflow contents filter (visual hide of others' private +# project folders). Kernel/terminal are not filtered. +c = get_config() # noqa: F821 +c.ServerApp.jpserver_extensions = {"jupyter_tenant_filter": True} diff --git a/gui/workflow_backend/django-project/neuroworkflow/jupyter_tenant_filter.py b/gui/workflow_backend/django-project/neuroworkflow/jupyter_tenant_filter.py new file mode 100644 index 00000000..5c09cd2f --- /dev/null +++ b/gui/workflow_backend/django-project/neuroworkflow/jupyter_tenant_filter.py @@ -0,0 +1,295 @@ +"""Jupyter ContentsManager filter: hide project dirs the opener may not see. + +This is a *visual* filter for the Lab file browser. The kernel and terminal +still see every path mounted in this container. Isolation between the +internal and hackathon Labs is done with separate bind-mounts, not here. + +The opener's identity comes from a short-lived NeuroWorkflow viewer token +(query ``nw_viewer`` or cookie ``nw_viewer``). The Lab page URL carries +``?nw_viewer=``; ``prepare()`` copies it onto a cookie so later +``/api/contents`` XHRs (which do not keep the query string) still identify +the opener. +""" + +from __future__ import annotations + +import inspect +import json +import os +import re +import urllib.error +import urllib.request +from collections import OrderedDict +from contextvars import ContextVar +from typing import Iterable +from uuid import UUID + +_UUID_RE = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + +_viewer_token: ContextVar[str | None] = ContextVar("nw_viewer_token", default=None) +_allowlist_cache: OrderedDict[str, tuple[float, dict]] = OrderedDict() +_CACHE_TTL_SECONDS = 30.0 +_CACHE_MAX = 64 + +PROJECTS_PREFIXES = ( + "codes/projects", + "/codes/projects", + "codes/projects/", + "/codes/projects/", +) + + +def _normalize_path(path: str) -> str: + path = (path or "").replace("\\", "/").strip("/") + while path.startswith("./"): + path = path[2:] + return path + + +def is_projects_root(path: str) -> bool: + return _normalize_path(path) in ("codes/projects", "codes/projects/") + + +def project_id_from_path(path: str) -> str | None: + normalized = _normalize_path(path) + parts = normalized.split("/") + if len(parts) >= 3 and parts[0] == "codes" and parts[1] == "projects": + return parts[2] + return None + + +def is_uuid_name(name: str) -> bool: + if not _UUID_RE.match(name or ""): + return False + try: + UUID(name) + return True + except ValueError: + return False + + +def filter_directory_entries( + path: str, + entries: Iterable[dict], + *, + project_ids: Iterable[str] | None, + legacy_names: Iterable[str] | None = None, + fail_closed: bool = True, +) -> list[dict]: + """Drop project directories that are not on the allow-list. + + Non-project paths are left unchanged. Files (README, etc.) in the + projects root stay visible. Directories that look like project ids or + legacy capitalized names are filtered. + """ + allowed_ids = {str(x) for x in (project_ids or [])} + allowed_legacy = {str(x) for x in (legacy_names or [])} + parent = _normalize_path(path) + looking_at_projects = parent in ("codes/projects",) + + out = [] + for entry in entries: + name = str(entry.get("name") or entry.get("path") or "") + kind = entry.get("type") or entry.get("content_type") + if looking_at_projects and kind in (None, "directory", "dir"): + if not name or name in (".", ".."): + continue + if is_uuid_name(name): + if name in allowed_ids: + out.append(entry) + elif not fail_closed and not allowed_ids: + out.append(entry) + continue + if name in allowed_legacy or name in allowed_ids: + out.append(entry) + continue + # Unknown directory name under projects/: hide unless it is clearly + # not a project folder (we treat every directory as a project). + continue + nested_id = project_id_from_path(f"{parent}/{name}") if parent else None + if nested_id and is_uuid_name(nested_id) and nested_id not in allowed_ids: + if fail_closed or allowed_ids: + continue + out.append(entry) + return out + + +def path_is_allowed( + path: str, + *, + project_ids: Iterable[str] | None, + legacy_names: Iterable[str] | None = None, + fail_closed: bool = True, +) -> bool: + project_id = project_id_from_path(path) + if not project_id: + return True + allowed_ids = {str(x) for x in (project_ids or [])} + allowed_legacy = {str(x) for x in (legacy_names or [])} + if project_id in allowed_ids or project_id in allowed_legacy: + return True + if not fail_closed and not allowed_ids: + return True + return False + + +def _backend_url() -> str: + return os.environ.get("NEUROWORKFLOW_BACKEND_URL", "http://backend:3000").rstrip("/") + + +def _open_allowlist() -> dict: + """Do not hide project dirs (used when the opener is unknown).""" + return { + "project_ids": [], + "legacy_names": [], + "hide_unlisted_projects": False, + } + + +def fetch_allowlist(token: str | None) -> dict: + import time + + if not token: + return _open_allowlist() + now = time.time() + cached = _allowlist_cache.get(token) + if cached and now - cached[0] < _CACHE_TTL_SECONDS: + _allowlist_cache.move_to_end(token) + return cached[1] + url = f"{_backend_url()}/api/workflow/jupyter/visible-paths/" + req = urllib.request.Request( + url, + headers={ + "Authorization": f"Viewer {token}", + "Accept": "application/json", + }, + method="GET", + ) + try: + with urllib.request.urlopen(req, timeout=5) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, ValueError): + # Unknown opener / backend hiccup: show the Lab tree rather than 404 + # every project. Kernel/terminal already see the same paths. + payload = _open_allowlist() + if "hide_unlisted_projects" not in payload: + payload["hide_unlisted_projects"] = True + while len(_allowlist_cache) >= _CACHE_MAX: + _allowlist_cache.popitem(last=False) + _allowlist_cache[token] = (now, payload) + return payload + + +def _apply_listing_filter(model, path, content, allow: dict): + if not allow.get("hide_unlisted_projects", True): + return model + if ( + content + and isinstance(model, dict) + and model.get("type") == "directory" + and model.get("content") + ): + model["content"] = filter_directory_entries( + path, + model["content"], + project_ids=allow.get("project_ids") or [], + legacy_names=allow.get("legacy_names") or [], + ) + return model + + +def _capture_viewer_token(handler, serverapp) -> None: + """Copy ``nw_viewer`` from the Lab URL onto a cookie for later API calls.""" + token = None + try: + token = handler.get_query_argument("nw_viewer", default=None) + if token: + base = getattr(serverapp, "base_url", None) or "/" + handler.set_cookie("nw_viewer", token, path=base, httponly=True) + else: + token = handler.get_cookie("nw_viewer") + except Exception: + token = None + try: + _viewer_token.set(token) + except Exception: + pass + + +def _load_jupyter_server_extension(serverapp): + """Register contents listing wrapper + session cookie endpoint.""" + try: + from jupyter_server.base.handlers import APIHandler + from jupyter_server.utils import url_path_join + import tornado.web + except ImportError: + serverapp.log.warning("jupyter_tenant_filter: jupyter_server not available") + return + + orig_prepare = tornado.web.RequestHandler.prepare + + def prepare(self, *args, **kwargs): + _capture_viewer_token(self, serverapp) + return orig_prepare(self, *args, **kwargs) + + tornado.web.RequestHandler.prepare = prepare + + class NWSessionHandler(APIHandler): + auth_resource = "contents" + + @tornado.web.authenticated + def post(self): + body = self.get_json_body() or {} + token = ( + body.get("token") + or self.get_argument("nw_viewer", default=None) + or self.get_cookie("nw_viewer") + ) + if not token: + raise tornado.web.HTTPError(400, "Missing viewer token") + base = getattr(serverapp, "base_url", None) or "/" + self.set_cookie("nw_viewer", token, path=base, httponly=True) + _viewer_token.set(token) + self.finish({"ok": True}) + + def check_xsrf_cookie(self): + return + + original_get = serverapp.contents_manager.get + + def filtered_get(path, content=True, type=None, format=None, **kwargs): + # Listing filter only — never 404 a path here. JupyterLab also + # POSTs /api/contents//checkpoints; a catch-all ContentsHandler + # stole that route and broke notebook saves. + result = original_get(path, content=content, type=type, format=format, **kwargs) + token = _viewer_token.get() + if inspect.isawaitable(result): + + async def _wrapped(): + import asyncio + + model = await result + loop = asyncio.get_running_loop() + allow = await loop.run_in_executor(None, fetch_allowlist, token) + return _apply_listing_filter(model, path, content, allow) + + return _wrapped() + allow = fetch_allowlist(token) + return _apply_listing_filter(result, path, content, allow) + + serverapp.contents_manager.get = filtered_get + + base = serverapp.base_url + handlers = [ + (url_path_join(base, "api/nw-session"), NWSessionHandler), + ] + try: + serverapp.web_app.add_handlers(".*$", handlers) + except Exception as exc: + serverapp.log.warning("jupyter_tenant_filter: failed to add handlers: %s", exc) + + +def _jupyter_server_extension_points(): + return [{"module": "jupyter_tenant_filter"}] diff --git a/gui/workflow_backend/django-project/neuroworkflow/jupyterhub_config.py b/gui/workflow_backend/django-project/neuroworkflow/jupyterhub_config.py index 9e5a4d7f..22b25328 100644 --- a/gui/workflow_backend/django-project/neuroworkflow/jupyterhub_config.py +++ b/gui/workflow_backend/django-project/neuroworkflow/jupyterhub_config.py @@ -23,6 +23,7 @@ # Remove containers when they stop c.DockerSpawner.remove = True +c.DockerSpawner.name_template = "jupyter-{username}" # Volume mounts - Get host path from .env file host_project_path = os.environ.get("HOST_PROJECT_PATH") @@ -36,35 +37,77 @@ host_claude_path = os.environ.get("HOST_CLAUDE_PATH") or os.path.normpath( os.path.join(host_project_path, "..", "..", "..", ".claude") ) +host_hackathon_path = os.environ.get("HOST_HACKATHON_PATH") or os.path.join( + host_project_path, "codes-hackathon" +) -c.DockerSpawner.volumes = { - f"{host_project_path}/codes/nodes": { - "bind": "/home/jovyan/codes/nodes", - "mode": "rw", - }, - f"{host_project_path}/codes/projects": { - "bind": "/home/jovyan/codes/projects", - "mode": "rw", - }, - f"{host_project_path}/codes/neuroworkflow": { - "bind": "/home/jovyan/codes/neuroworkflow", - "mode": "rw", - }, - host_claude_path: { - "bind": "/home/jovyan/.claude", - "mode": "ro", - }, - # "jupyterhub-user-{username}": {"bind": "/home/jovyan/work", "mode": "rw"}, -} + +def _hub_tenant(username: str) -> str: + if username == "hackathon": + return "hackathon" + return "internal" + + +def _volumes_for_username(username: str) -> dict: + tenant = _hub_tenant(username) + if tenant == "hackathon": + nodes_src = f"{host_hackathon_path}/nodes" + projects_src = f"{host_hackathon_path}/projects" + lib_mode = "ro" + else: + nodes_src = f"{host_project_path}/codes/nodes" + projects_src = f"{host_project_path}/codes/projects" + lib_mode = "rw" + return { + nodes_src: {"bind": "/home/jovyan/codes/nodes", "mode": "rw"}, + projects_src: {"bind": "/home/jovyan/codes/projects", "mode": "rw"}, + f"{host_project_path}/codes/neuroworkflow": { + "bind": "/home/jovyan/codes/neuroworkflow", + "mode": lib_mode, + }, + host_claude_path: {"bind": "/home/jovyan/.claude", "mode": "ro"}, + f"{host_project_path}/neuroworkflow/jupyter_tenant_filter.py": { + "bind": "/home/jovyan/jupyter_tenant_filter.py", + "mode": "ro", + }, + f"{host_project_path}/neuroworkflow/jupyter_server_config.py": { + "bind": "/home/jovyan/.jupyter/jupyter_server_config.py", + "mode": "ro", + }, + f"{host_project_path}/neuroworkflow/PRIVACY_NOTICE.md": { + "bind": "/home/jovyan/PRIVACY_NOTICE.md", + "mode": "ro", + }, + } + + +def pre_spawn_hook(spawner): + username = spawner.user.name + tenant = _hub_tenant(username) + spawner.volumes = _volumes_for_username(username) + spawner.environment["NW_JUPYTER_TENANT"] = tenant + spawner.environment["PYTHONPATH"] = "/home/jovyan:/home/jovyan/codes" + + +c.DockerSpawner.pre_spawn_hook = pre_spawn_hook +c.DockerSpawner.volumes = _volumes_for_username("internal") + +_mem_limit = os.environ.get("JUPYTER_MEM_LIMIT", "").strip() +if _mem_limit: + c.DockerSpawner.mem_limit = _mem_limit +_cpu_limit = os.environ.get("JUPYTER_CPU_LIMIT", "").strip() +if _cpu_limit: + c.DockerSpawner.cpu_limit = float(_cpu_limit) # Environment variables for spawned containers c.DockerSpawner.environment = { - "GRANT_SUDO": os.environ.get("JUPYTER_GRANT_SUDO", "yes"), + "GRANT_SUDO": os.environ.get("JUPYTER_GRANT_SUDO", "no"), "CHOWN_HOME": "yes", "JUPYTER_CONFIG_DIR": "/home/jovyan/.jupyter", # Make `import neuroworkflow` (and neuroworkflow.agent) resolve from the - # mounted codes/ tree without per-notebook sys.path hacks. - "PYTHONPATH": "/home/jovyan/codes", + # mounted codes/ tree without per-notebook sys.path hacks. /home/jovyan is + # included so jupyter_tenant_filter.py can be imported. + "PYTHONPATH": "/home/jovyan:/home/jovyan/codes", # Wiring for the in-notebook chat agent (Issue #52). # NOTE: do NOT set JUPYTERHUB_API_TOKEN here — JupyterHub injects a # per-server token under that name for the single-user server's own OAuth @@ -107,13 +150,16 @@ c.DockerSpawner.args = [ f"--ServerApp.tornado_settings={{'headers':{{'Content-Security-Policy':\"frame-ancestors {_frame_ancestors}\"}}}}", f"--ServerApp.allow_origin={_frame_origin}", + "--ServerApp.jpserver_extensions={'jupyter_tenant_filter': True}", ] if os.environ.get("JUPYTERHUB_DISABLE_XSRF", "false").lower() == "true": c.DockerSpawner.args.append("--ServerApp.disable_check_xsrf=True") _allowed_users = { user.strip() - for user in os.environ.get("JUPYTERHUB_ALLOWED_USERS", "").split(",") + for user in os.environ.get( + "JUPYTERHUB_ALLOWED_USERS", "internal,hackathon,user1" + ).split(",") if user.strip() } if _allowed_users: @@ -121,8 +167,20 @@ if os.environ.get("JUPYTERHUB_AUTHENTICATOR", "dummy").lower() == "firstuse": # First-use authentication stores per-user passwords for production. - c.JupyterHub.authenticator_class = "firstuseauthenticator.FirstUseAuthenticator" - c.FirstUseAuthenticator.create_users = False + # `user1` is the pre-cutover Hub account; treat it as `internal` so the + # GUI URL /user/internal/ matches the Hub cookie after login. + from firstuseauthenticator import FirstUseAuthenticator + + class AliasFirstUseAuthenticator(FirstUseAuthenticator): + create_users = False + + async def authenticate(self, handler, data): + username = await super().authenticate(handler, data) + if username == "user1": + return "internal" + return username + + c.JupyterHub.authenticator_class = AliasFirstUseAuthenticator else: # Plain docker compose remains a local/dev stack. c.JupyterHub.authenticator_class = "jupyterhub.auth.DummyAuthenticator" @@ -207,7 +265,15 @@ "admin:users", # read user model (needed for server status) ], "services": ["backend"], - } + }, + # Existing Hub cookies may still say user1 while the GUI opens /user/internal/. + { + "name": "user1-internal-alias", + "users": ["user1"], + "scopes": [ + "access:servers!user=internal", + ], + }, ] # ----Regular cleanup diff --git a/gui/workflow_backend/django-project/tests/test_jupyter_listing_filter.py b/gui/workflow_backend/django-project/tests/test_jupyter_listing_filter.py new file mode 100644 index 00000000..92ec2734 --- /dev/null +++ b/gui/workflow_backend/django-project/tests/test_jupyter_listing_filter.py @@ -0,0 +1,87 @@ +"""Pure listing-filter tests (no Jupyter server required).""" +import importlib.util +from pathlib import Path + +FILTER_PATH = ( + Path(__file__).resolve().parents[1] / "neuroworkflow" / "jupyter_tenant_filter.py" +) +spec = importlib.util.spec_from_file_location("jupyter_tenant_filter", FILTER_PATH) +mod = importlib.util.module_from_spec(spec) +assert spec.loader is not None +spec.loader.exec_module(mod) + + +def test_hides_other_uuid_dirs(): + allowed = ["11111111-1111-1111-1111-111111111111"] + hidden = "22222222-2222-2222-2222-222222222222" + entries = [ + {"name": allowed[0], "type": "directory"}, + {"name": hidden, "type": "directory"}, + {"name": "README.md", "type": "file"}, + ] + out = mod.filter_directory_entries( + "codes/projects", + entries, + project_ids=allowed, + legacy_names=[], + ) + names = {e["name"] for e in out} + assert allowed[0] in names + assert hidden not in names + assert "README.md" in names + + +def test_fail_closed_without_allowlist(): + uuid_name = "11111111-1111-1111-1111-111111111111" + out = mod.filter_directory_entries( + "codes/projects", + [{"name": uuid_name, "type": "directory"}], + project_ids=[], + fail_closed=True, + ) + assert out == [] + + +def test_nested_project_path_denied(): + assert not mod.path_is_allowed( + "codes/projects/22222222-2222-2222-2222-222222222222/workflow.py", + project_ids=["11111111-1111-1111-1111-111111111111"], + ) + assert mod.path_is_allowed( + "codes/nodes/analysis/Foo.py", + project_ids=[], + ) + + +def test_fetch_allowlist_without_token_is_open(): + payload = mod.fetch_allowlist(None) + assert payload["hide_unlisted_projects"] is False + + +def test_path_allowed_when_filter_disabled(): + path = "codes/projects/22222222-2222-2222-2222-222222222222/workflow.py" + assert mod.path_is_allowed(path, project_ids=[], fail_closed=False) + + +def test_projects_root_is_never_denied(): + assert mod.path_is_allowed("codes/projects", project_ids=[], fail_closed=True) + assert mod.path_is_allowed("/codes/projects", project_ids=[], fail_closed=True) + assert mod.path_is_allowed("codes", project_ids=[], fail_closed=True) + + +def test_allowlist_cache_evicts(): + mod._allowlist_cache.clear() + old_max = mod._CACHE_MAX + mod._CACHE_MAX = 2 + try: + for i in range(3): + token = f"tok-{i}" + mod._allowlist_cache[token] = (0.0, {"project_ids": [str(i)]}) + while len(mod._allowlist_cache) > mod._CACHE_MAX: + mod._allowlist_cache.popitem(last=False) + assert "tok-0" not in mod._allowlist_cache + assert "tok-1" in mod._allowlist_cache + assert "tok-2" in mod._allowlist_cache + finally: + mod._CACHE_MAX = old_max + mod._allowlist_cache.clear() diff --git a/gui/workflow_backend/django-project/tests/test_node_governance.py b/gui/workflow_backend/django-project/tests/test_node_governance.py new file mode 100644 index 00000000..4b3e10bf --- /dev/null +++ b/gui/workflow_backend/django-project/tests/test_node_governance.py @@ -0,0 +1,202 @@ +"""Node governance pipeline is tenant-scoped.""" +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse + +from app.box.models import NodeAuditLog, PythonFile +from app.tenants import ( + GROUP_NODE_REVIEWERS, + TENANT_HACKATHON, + TENANT_INTERNAL, + ensure_tenant_groups, + set_user_tenant, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def reviewer(db, django_user_model): + user = django_user_model.objects.create_user( + username="reviewer-sub", email="reviewer@example.com" + ) + groups = ensure_tenant_groups() + user.groups.add(groups[GROUP_NODE_REVIEWERS]) + set_user_tenant(user, TENANT_INTERNAL) + return user + + +@pytest.fixture +def guest(db, django_user_model): + user = django_user_model.objects.create_user( + username="guest-gov", email="guest-gov@example.com" + ) + set_user_tenant(user, TENANT_HACKATHON) + return user + + +def _make_node(owner, *, tenant=TENANT_INTERNAL, status=PythonFile.Status.PRIVATE, name="n.py"): + return PythonFile.objects.create( + name=name, + category="analysis", + file_content="class Foo:\n pass\n", + file_hash=f"hash-{owner.id}-{name}-{tenant}", + uploaded_by=owner, + tenant=tenant, + status=status, + is_analyzed=True, + node_classes={"Foo": {"description": "", "inputs": {}, "outputs": {}, "parameters": {}, "methods": {}}}, + ) + + +def test_owner_submit_reviewer_approve_public(auth_client, user_alice, reviewer): + node = _make_node(user_alice) + submit_url = reverse("box:node-submit", args=[node.id]) + resp = auth_client(user_alice).post(submit_url, {}, format="json") + assert resp.status_code == 200, resp.content + node.refresh_from_db() + assert node.status == PythonFile.Status.SUBMITTED + + approve_url = reverse("box:node-approve", args=[node.id]) + resp = auth_client(reviewer).post( + approve_url, {"make_public": True, "comment": "ok"}, format="json" + ) + assert resp.status_code == 200, resp.content + node.refresh_from_db() + assert node.status == PythonFile.Status.PUBLIC + assert NodeAuditLog.objects.filter(python_file=node, action="published").exists() + + +def test_stranger_cannot_submit(auth_client, user_alice, user_bob): + node = _make_node(user_alice) + url = reverse("box:node-submit", args=[node.id]) + resp = auth_client(user_bob).post(url, {}, format="json") + assert resp.status_code in (403, 404) + + +def test_non_reviewer_cannot_approve(auth_client, user_alice): + node = _make_node(user_alice, status=PythonFile.Status.SUBMITTED) + url = reverse("box:node-approve", args=[node.id]) + resp = auth_client(user_alice).post(url, {"make_public": True}, format="json") + assert resp.status_code == 403 + + +def test_palette_hides_other_private_and_other_tenant( + auth_client, user_alice, user_bob, guest +): + own = _make_node(user_alice, name="alice.py") + bob_private = _make_node(user_bob, name="bob.py") + catalog = PythonFile.objects.create( + name="catalog.py", + category="analysis", + file_content="class Cat:\n pass\n", + file_hash="hash-catalog-internal", + uploaded_by=None, + tenant=TENANT_INTERNAL, + status=PythonFile.Status.PUBLIC, + is_analyzed=True, + node_classes={"Cat": {"description": "", "inputs": {}, "outputs": {}, "parameters": {}, "methods": {}}}, + ) + guest_public = _make_node( + guest, + tenant=TENANT_HACKATHON, + status=PythonFile.Status.PUBLIC, + name="guest.py", + ) + + resp = auth_client(user_alice).get(reverse("box:uploaded-nodes")) + assert resp.status_code == 200 + names = {n["file_name"] for n in resp.json()["nodes"]} + assert own.name in names + assert catalog.name in names + assert bob_private.name not in names + assert guest_public.name not in names + + +def test_approve_in_one_tenant_does_not_publish_to_the_other( + auth_client, user_alice, reviewer, guest +): + node = _make_node(user_alice, status=PythonFile.Status.SUBMITTED) + resp = auth_client(reviewer).post( + reverse("box:node-approve", args=[node.id]), + {"make_public": True}, + format="json", + ) + assert resp.status_code == 200 + guest_resp = auth_client(guest).get(reverse("box:uploaded-nodes")) + names = {n["file_name"] for n in guest_resp.json()["nodes"]} + assert node.name not in names + + +def test_owner_keeps_own_node_after_tenant_move(auth_client, user_alice, guest): + node = _make_node(user_alice, name="moved.py") + set_user_tenant(user_alice, TENANT_HACKATHON) + resp = auth_client(user_alice).get(reverse("box:uploaded-nodes")) + names = {n["file_name"] for n in resp.json()["nodes"]} + assert node.name in names + guest_names = { + n["file_name"] for n in auth_client(guest).get(reverse("box:uploaded-nodes")).json()["nodes"] + } + assert node.name not in guest_names + + +def test_reviewer_can_publish_approved_without_make_public( + auth_client, user_alice, reviewer +): + node = _make_node(user_alice, status=PythonFile.Status.SUBMITTED) + resp = auth_client(reviewer).post( + reverse("box:node-approve", args=[node.id]), {}, format="json" + ) + assert resp.status_code == 200, resp.content + node.refresh_from_db() + assert node.status == PythonFile.Status.APPROVED + + resp = auth_client(reviewer).post( + reverse("box:node-publish", args=[node.id]), {}, format="json" + ) + assert resp.status_code == 200, resp.content + node.refresh_from_db() + assert node.status == PythonFile.Status.PUBLIC + + +def test_reviewer_cannot_approve_own_node(auth_client, reviewer): + node = _make_node(reviewer, status=PythonFile.Status.SUBMITTED, name="self.py") + resp = auth_client(reviewer).post( + reverse("box:node-approve", args=[node.id]), + {"make_public": True}, + format="json", + ) + assert resp.status_code == 403 + node.refresh_from_db() + assert node.status == PythonFile.Status.SUBMITTED + + +def test_identical_hash_does_not_steal_ownership(user_alice, user_bob, tmp_path, settings): + from django.core.files.uploadedfile import SimpleUploadedFile + from app.box.services.python_file_service import PythonFileService + + settings.MEDIA_ROOT = str(tmp_path) + (tmp_path / "analysis").mkdir() + src = "class Foo:\n pass\n" + node = _make_node(user_alice, name="orig.py") + node.file_hash = "will-be-replaced" + node.file_content = src + node.save() + import hashlib + + digest = hashlib.sha256(src.encode("utf-8")).hexdigest() + node.file_hash = digest + node.save(update_fields=["file_hash"]) + + service = PythonFileService() + uploaded = SimpleUploadedFile("copy.py", src.encode("utf-8"), content_type="text/x-python") + try: + service.create_python_file( + uploaded, user=user_bob, name="copy.py", category="analysis", tenant=TENANT_INTERNAL + ) + raised = False + except ValueError: + raised = True + assert raised + node.refresh_from_db() + assert node.uploaded_by_id == user_alice.id diff --git a/gui/workflow_backend/django-project/tests/test_tenants.py b/gui/workflow_backend/django-project/tests/test_tenants.py new file mode 100644 index 00000000..57d46937 --- /dev/null +++ b/gui/workflow_backend/django-project/tests/test_tenants.py @@ -0,0 +1,164 @@ +"""Tenant isolation for FlowProject list/detail and Jupyter visible-paths.""" +import pytest +from django.urls import reverse + +from app.tenants import ( + TENANT_HACKATHON, + TENANT_INTERNAL, + get_user_tenant, + hub_username_for_tenant, + set_user_tenant, + tenant_from_claims, +) +from app.workflow.models import FlowProject +from app.workflow.viewer_tokens import mint_viewer_token + +pytestmark = pytest.mark.django_db + + +def _make_project(owner, *, visibility="private", name="P", tenant=None): + if tenant is None: + tenant = get_user_tenant(owner) + return FlowProject.objects.create( + name=name, owner=owner, visibility=visibility, tenant=tenant + ) + + +@pytest.fixture +def user_guest(db, django_user_model): + user = django_user_model.objects.create_user( + username="guest-sub-uuid", email="guest@example.com" + ) + set_user_tenant(user, TENANT_HACKATHON) + return user + + +def test_default_tenant_is_internal(user_alice): + project = FlowProject.objects.create(name="X", owner=user_alice) + assert project.tenant == TENANT_INTERNAL + assert get_user_tenant(user_alice) == TENANT_INTERNAL + + +def test_create_assigns_caller_tenant(auth_client, user_guest): + client = auth_client(user_guest) + list_url = reverse("workflow:workflow-list-create") + resp = client.post(list_url, {"name": "GuestProj", "tenant": "internal"}, format="json") + assert resp.status_code == 201 + project = FlowProject.objects.get(id=resp.json()["id"]) + assert project.tenant == TENANT_HACKATHON + assert resp.json()["tenant"] == TENANT_HACKATHON + + +def test_internal_user_cannot_see_hackathon_public( + auth_client, user_alice, user_guest +): + project = _make_project( + user_guest, visibility="public", name="GuestPublic", tenant=TENANT_HACKATHON + ) + client = auth_client(user_alice) + + list_url = reverse("workflow:workflow-list-create") + resp = client.get(list_url) + assert resp.status_code == 200 + ids = [p["id"] for p in resp.json()] + assert str(project.id) not in ids + + detail_url = reverse("workflow:workflow-detail", args=[project.id]) + assert client.get(detail_url).status_code == 404 + assert client.patch(detail_url, {"description": "x"}, format="json").status_code == 404 + + +def test_hackathon_user_cannot_see_internal_public( + auth_client, user_alice, user_guest +): + project = _make_project( + user_alice, visibility="public", name="InternalPublic", tenant=TENANT_INTERNAL + ) + client = auth_client(user_guest) + detail_url = reverse("workflow:workflow-detail", args=[project.id]) + assert client.get(detail_url).status_code == 404 + + list_url = reverse("workflow:workflow-list-create") + resp = client.get(list_url) + ids = [p["id"] for p in resp.json()] + assert str(project.id) not in ids + + +def test_same_tenant_public_still_visible(auth_client, user_alice, user_bob): + project = _make_project(user_alice, visibility="public") + resp = auth_client(user_bob).get( + reverse("workflow:workflow-detail", args=[project.id]) + ) + assert resp.status_code == 200 + + +def test_jupyter_session_and_visible_paths(auth_client, user_alice, user_bob, user_guest): + own = _make_project(user_alice, visibility="private", name="AlicePrivate") + pub = _make_project(user_alice, visibility="public", name="AlicePublic") + bob_private = _make_project(user_bob, visibility="private", name="BobPrivate") + guest_pub = _make_project( + user_guest, visibility="public", name="GuestPublic", tenant=TENANT_HACKATHON + ) + + session = auth_client(user_alice).get(reverse("workflow:jupyter-session")) + assert session.status_code == 200 + body = session.json() + assert body["tenant"] == TENANT_INTERNAL + assert body["hub_user"] == hub_username_for_tenant(TENANT_INTERNAL) + assert body["viewer_token"] + + token = body["viewer_token"] + client = auth_client() + resp = client.get( + reverse("workflow:jupyter-visible-paths"), + HTTP_AUTHORIZATION=f"Viewer {token}", + ) + assert resp.status_code == 200 + ids = set(resp.json()["project_ids"]) + assert str(own.id) in ids + assert str(pub.id) in ids + assert str(bob_private.id) not in ids + assert str(guest_pub.id) not in ids + + +def test_minted_token_matches_user(user_alice): + token = mint_viewer_token(user_alice) + from app.workflow.viewer_tokens import user_from_viewer_token + + user, payload = user_from_viewer_token(token) + assert user.id == user_alice.id + assert payload["tenant"] == TENANT_INTERNAL + + +def test_tenant_claims_match_exact_group_names(): + assert tenant_from_claims({"groups": ["/nw-internal"]}) == TENANT_INTERNAL + assert tenant_from_claims({"groups": ["nw-hackathon"]}) == TENANT_HACKATHON + assert tenant_from_claims({"groups": ["/teams/nw-internal-mentees"]}) is None + assert tenant_from_claims({"realm_access": {"roles": ["nw-internal-readonly"]}}) is None + + +def test_owner_still_lists_own_project_after_tenant_move(auth_client, user_alice, user_guest): + project = _make_project(user_alice, visibility="private", name="My Project") + set_user_tenant(user_alice, TENANT_HACKATHON) + resp = auth_client(user_alice).get(reverse("workflow:workflow-list-create")) + ids = [p["id"] for p in resp.json()] + assert str(project.id) in ids + guest_ids = [ + p["id"] + for p in auth_client(user_guest).get(reverse("workflow:workflow-list-create")).json() + ] + assert str(project.id) not in guest_ids + + +def test_visible_paths_legacy_name_matches_disk(auth_client, user_alice): + from app.workflow.path_utils import legacy_project_dir + + project = _make_project(user_alice, visibility="private", name="My Project") + token = mint_viewer_token(user_alice) + resp = auth_client().get( + reverse("workflow:jupyter-visible-paths"), + HTTP_AUTHORIZATION=f"Viewer {token}", + ) + assert resp.status_code == 200 + names = set(resp.json()["legacy_names"]) + assert legacy_project_dir(project).name in names diff --git a/gui/workflow_backend/env.template b/gui/workflow_backend/env.template index 42c6e89e..27c5e287 100644 --- a/gui/workflow_backend/env.template +++ b/gui/workflow_backend/env.template @@ -60,3 +60,7 @@ PYTHONENV=/usr/bin/python3 # LOCAL_RAG_TIMEOUT=90 # /global_query can take 30–90s; increase if RAG is slow # LOCAL_RAG_MAX_CHUNKS=10 # LOCAL_RAG_SOURCE_NAME=Local RAG + +# --- Jupyter execution (tenant Hub user is chosen in code; this is a fallback) --- +# JUPYTER_EXECUTION_USER=internal + diff --git a/gui/workflow_frontend/src/api/jupyterTenant.ts b/gui/workflow_frontend/src/api/jupyterTenant.ts new file mode 100644 index 00000000..28e99b53 --- /dev/null +++ b/gui/workflow_frontend/src/api/jupyterTenant.ts @@ -0,0 +1,61 @@ +import { createAuthHeaders } from "./authHeaders"; +import { JUPYTER_BASE_URL } from "../config/urls"; + +export type Tenant = "internal" | "hackathon"; + +export interface JupyterSession { + tenant: Tenant; + hub_user: string; + jupyter_path: string; + viewer_token: string; + is_node_reviewer: boolean; + notice: string; +} + +let cached: JupyterSession | null = null; +let inflight: Promise | null = null; + +export async function getJupyterSession(force = false): Promise { + if (!force && cached) { + return cached; + } + if (!force && inflight) { + return inflight; + } + inflight = (async () => { + const headers = await createAuthHeaders(); + const response = await fetch("/api/workflow/jupyter/session/", { + credentials: "include", + headers, + }); + if (!response.ok) { + throw new Error(`Failed to load Jupyter session (${response.status})`); + } + const data = (await response.json()) as JupyterSession; + cached = data; + return data; + })(); + try { + return await inflight; + } finally { + inflight = null; + } +} + +export function jupyterTreeUrl( + treePath: string, + session: JupyterSession, +): string { + const trimmed = treePath.replace(/^\/+/, ""); + const base = `${JUPYTER_BASE_URL}/user/${session.hub_user}/lab/tree/${trimmed}`; + if (!session.viewer_token) { + return base; + } + const sep = base.includes("?") ? "&" : "?"; + return `${base}${sep}nw_viewer=${encodeURIComponent(session.viewer_token)}`; +} + +export async function openJupyterTree(treePath: string): Promise { + const session = await getJupyterSession(); + return jupyterTreeUrl(treePath, session); +} diff --git a/gui/workflow_frontend/src/hooks/useUploadedNodes.ts b/gui/workflow_frontend/src/hooks/useUploadedNodes.ts index aba73eb8..e8167064 100644 --- a/gui/workflow_frontend/src/hooks/useUploadedNodes.ts +++ b/gui/workflow_frontend/src/hooks/useUploadedNodes.ts @@ -9,6 +9,8 @@ interface UploadedNodesResponse { nodes: BackendNodeType[]; total_files: number; total_nodes: number; + is_node_reviewer?: boolean; + tenant?: string; } interface BackendNodeType { @@ -22,6 +24,9 @@ interface BackendNodeType { file_name: string; schema: SchemaFields; color: string; + status?: string; + tenant?: string; + can_submit?: boolean; } // interface SchemaField { diff --git a/gui/workflow_frontend/src/views/box/boxView.tsx b/gui/workflow_frontend/src/views/box/boxView.tsx index e6edef6f..c87c6d3d 100644 --- a/gui/workflow_frontend/src/views/box/boxView.tsx +++ b/gui/workflow_frontend/src/views/box/boxView.tsx @@ -39,7 +39,7 @@ import { IconType } from 'react-icons'; import { FiBox, FiCopy, FiTrash2, FiEdit2, FiCode, FiRefreshCw, FiChevronDown, FiChevronRight, FiMenu } from 'react-icons/fi'; // Use as default icon import { SchemaFields } from '../home/type'; import { createAuthHeaders } from '../../api/authHeaders'; -import { JUPYTER_BASE_URL } from '../../config/urls'; +import { openJupyterTree } from '../../api/jupyterTenant'; import { useTabContext } from '../../components/tabs/TabManager'; interface SidebarProps { @@ -58,6 +58,8 @@ interface UploadedNodesResponse { nodes: BackendNodeType[]; total_files: number; total_nodes: number; + is_node_reviewer?: boolean; + tenant?: string; } interface BackendNodeType { @@ -71,6 +73,9 @@ interface BackendNodeType { file_name: string; schema: SchemaFields; color: string; + status?: string; + tenant?: string; + can_submit?: boolean; } interface NodeTypeWithIcon extends Omit { @@ -380,6 +385,29 @@ const SideBoxArea: React.FC = ({ nodes, isLoading = false, error, } }; + const postNodeGovernance = async (fileId: string, action: "submit" | "approve" | "reject") => { + const headers = await createAuthHeaders(); + const body = action === "approve" ? { make_public: true } : {}; + const response = await fetch(`/api/box/files/${fileId}/${action}/`, { + method: "POST", + credentials: "include", + headers, + body: JSON.stringify(body), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || `HTTP ${response.status}`); + } + await onRefresh?.(); + }; + + const statusColor = (status?: string) => { + if (status === "public") return "green"; + if (status === "submitted") return "orange"; + if (status === "approved") return "blue"; + return "gray"; + }; + // Open copy dialog const openCopyDialog = (node: NodeTypeWithIcon) => { if (!node.file_name) { @@ -511,17 +539,28 @@ const SideBoxArea: React.FC = ({ nodes, isLoading = false, error, }; // Open Jupyter in a new tab - const OpenJupyter = (filename : string, category : string) => { + const OpenJupyter = async (filename : string, category : string) => { const chkPy = filename.includes(".py"); if (!chkPy) { filename += ".py"; } - const jupyterUrl = JUPYTER_BASE_URL+"/user/user1/lab/workspaces/auto-E/tree/codes/nodes/"+category.replace('/','').toLowerCase()+"/"+filename - - let projectId = localStorage.getItem('projectId'); - projectId = projectId ? projectId : ""; - // Create new tab - addJupyterTab(projectId, filename, jupyterUrl); + try { + const jupyterUrl = await openJupyterTree( + "codes/nodes/"+category.replace('/','').toLowerCase()+"/"+filename + ); + + let projectId = localStorage.getItem('projectId'); + projectId = projectId ? projectId : ""; + addJupyterTab(projectId, filename, jupyterUrl); + } catch (err) { + toast({ + title: "Could not open Jupyter", + description: err instanceof Error ? err.message : "Failed to resolve the Jupyter URL", + status: "error", + duration: 4000, + isClosable: true, + }); + } }; return ( @@ -918,7 +957,68 @@ const SideBoxArea: React.FC = ({ nodes, isLoading = false, error, {node.label} + {node.status && node.status !== "public" && ( + + {node.status} + + )} + {(node.can_submit || (nodes?.is_node_reviewer && node.status === "submitted")) && ( + + {node.can_submit && ( + + )} + {nodes?.is_node_reviewer && node.status === "submitted" && ( + <> + + + + )} + + )} diff --git a/gui/workflow_frontend/src/views/home/components/calculationNode.tsx b/gui/workflow_frontend/src/views/home/components/calculationNode.tsx index eee81318..bf5ef1fe 100644 --- a/gui/workflow_frontend/src/views/home/components/calculationNode.tsx +++ b/gui/workflow_frontend/src/views/home/components/calculationNode.tsx @@ -12,7 +12,7 @@ import { import { EditIcon, DeleteIcon, ChevronDownIcon, ChevronUpIcon } from "@chakra-ui/icons"; import { FiCode, FiEye } from "react-icons/fi"; import { useTabContext } from '../../../components/tabs/TabManager'; -import { JUPYTER_BASE_URL } from '../../../config/urls'; +import { openJupyterTree } from '../../../api/jupyterTenant'; import { generateHandleId } from '@/utils/handleId'; interface NodeCallbacks { @@ -72,13 +72,24 @@ export const CalculationNode = ({ }, [isParamExpand, id, updateNodeInternals]); // Open Jupyter in a new tab - const OpenJupyter = (filename : string, category : string) => { - const jupyterUrl = JUPYTER_BASE_URL+"/user/user1/lab/workspaces/auto-E/tree/codes/nodes/"+category.replace('/','').toLowerCase()+"/"+filename; - - let projectId = localStorage.getItem('projectId'); - projectId = projectId ? projectId : ""; - // Create new tab - addJupyterTab(projectId, filename, jupyterUrl); + const OpenJupyter = async (filename : string, category : string) => { + try { + const jupyterUrl = await openJupyterTree( + "codes/nodes/"+category.replace('/','').toLowerCase()+"/"+filename + ); + + let projectId = localStorage.getItem('projectId'); + projectId = projectId ? projectId : ""; + addJupyterTab(projectId, filename, jupyterUrl); + } catch (err) { + toast({ + title: "Could not open Jupyter", + description: err instanceof Error ? err.message : "Failed to resolve the Jupyter URL", + status: "error", + duration: 4000, + isClosable: true, + }); + } }; const normalizeViewerOutputDir = (rawOutputDir?: unknown) => { diff --git a/gui/workflow_frontend/src/views/home/components/jupyterModal.tsx b/gui/workflow_frontend/src/views/home/components/jupyterModal.tsx index 57b5c412..74167568 100644 --- a/gui/workflow_frontend/src/views/home/components/jupyterModal.tsx +++ b/gui/workflow_frontend/src/views/home/components/jupyterModal.tsx @@ -26,6 +26,7 @@ import { } from '@chakra-ui/react'; import { ExternalLinkIcon, RepeatIcon, SettingsIcon, CopyIcon } from '@chakra-ui/icons'; import { JUPYTER_BASE_URL } from '../../../config/urls'; +import { getJupyterSession, jupyterTreeUrl } from '../../../api/jupyterTenant'; interface JupyterModalProps { isOpen: boolean; @@ -86,65 +87,14 @@ const JupyterModal: React.FC = ({ setStatus(prev => ({ ...prev, isLoading: true, error: null })); try { - let jupyterUrl: string; - - if (isDevelopment) { - // Development mode: Directly access the URL containing the project ID - jupyterUrl = `${JUPYTER_BASE_URL}/hub/login?username=user1&password=password`; - - console.log(`Development mode: Initializing Jupyter for project ${projectId}`); - console.log(`URL: ${jupyterUrl}`); - - // Simple wait (actual health check omitted) - await new Promise(resolve => setTimeout(resolve, 1500)); - - } else { - // Production mode: JWT authentication through the Django API - const requestBody: any = { - project_id: projectId, - }; - - // Add JWT token if available - if (jwtToken) { - requestBody.token = jwtToken; - } - - console.log(`Production mode: Requesting Jupyter for project ${projectId}`); - - const response = await fetch('/api/jupyterhub/launch/', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - // The JWT token is also included in the Authorization header. - ...(jwtToken && { - 'Authorization': `Bearer ${jwtToken}` - }), - }, - credentials: 'include', - body: JSON.stringify(requestBody), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || `HTTP ${response.status}: Failed to launch JupyterHub`); - } - - const data = await response.json(); - - // Use a URL containing the project ID even in production - jupyterUrl = data.jupyterhub_url || - `${jupyterBaseUrl}/project/${projectId}`; - - // If there is a token, add it to the URL (for iframes) - if (jwtToken && !data.jupyterhub_url) { - jupyterUrl += `?token=${jwtToken}`; - } - - console.log(`Production URL: ${jupyterUrl}`); - - // Wait for JupyterHub to be ready - await waitForJupyterReady(jupyterBaseUrl, projectId); - } + const session = await getJupyterSession(); + const tree = projectId + ? `codes/projects/${projectId}/` + : "codes/projects/"; + const jupyterUrl = jupyterTreeUrl(tree, session); + + console.log(`Initializing Jupyter for project ${projectId}`); + console.log(`URL: ${jupyterUrl}`); setStatus({ isLoading: false, diff --git a/gui/workflow_frontend/src/views/home/components/nodeDetailModal.tsx b/gui/workflow_frontend/src/views/home/components/nodeDetailModal.tsx index eb43c739..96f70c18 100644 --- a/gui/workflow_frontend/src/views/home/components/nodeDetailModal.tsx +++ b/gui/workflow_frontend/src/views/home/components/nodeDetailModal.tsx @@ -23,7 +23,6 @@ import { CalculationNodeData, SchemaFields } from '../type'; import { Node } from '@xyflow/react'; import { createAuthHeaders } from '../../../api/authHeaders'; import ParameterSuggestionModal from './ParameterSuggestionModal'; -import { JUPYTER_BASE_URL } from '../../../config/urls'; interface NodeDetailsContentProps { nodeData: Node | null; @@ -34,11 +33,6 @@ interface NodeDetailsContentProps { convertToStrIncFloat: (obj: any) => any; } -// Open Jupyter in a new tab -const OpenJupyter = (filename : string, category : string) => { - window.open(`${JUPYTER_BASE_URL}/user/user1/lab/workspaces/auto-E/tree/codes/nodes/${category.toLowerCase()}/${filename}.py`, "_blank"); -}; - const NodeDetailsContent: React.FC = ({ nodeData, onNodeUpdate, onRefreshNodeData, onViewCode, workflowId, convertToStrIncFloat }) => { const [editingInstance, setEditingInstance] = useState(''); const [editingParam, setEditingParam] = useState(null); diff --git a/gui/workflow_frontend/src/views/home/components/projectSelector.tsx b/gui/workflow_frontend/src/views/home/components/projectSelector.tsx index 161bbe62..85b13531 100644 --- a/gui/workflow_frontend/src/views/home/components/projectSelector.tsx +++ b/gui/workflow_frontend/src/views/home/components/projectSelector.tsx @@ -48,7 +48,7 @@ const emptyAttribution = (): AttributionDraft => ({ links: [], contributors: [], }); -import { JUPYTER_BASE_URL } from '../../../config/urls'; +import { openJupyterTree } from '../../../api/jupyterTenant'; export const ProjectSelector = ({ projects, @@ -297,10 +297,9 @@ export const ProjectSelector = ({ // Get project name const projectName = projects.find(p => p.id === selectedProject)?.name || selectedProject; // Initial capitalization - const trimedProjectName = projectName.replace(/\s/g, '').toLowerCase(); - const capitalizedProjectName = trimedProjectName.charAt(0).toUpperCase() + trimedProjectName.slice(1); - - const jupyterUrl = `${JUPYTER_BASE_URL}/user/user1/lab/workspaces/auto-E/tree/codes/projects/${capitalizedProjectName}/${capitalizedProjectName}.py`; + const jupyterUrl = await openJupyterTree( + `codes/projects/${selectedProject}/workflow.py` + ); // Create new tab addJupyterTab(selectedProject, projectName, jupyterUrl); diff --git a/gui/workflow_frontend/src/views/home/homeView.tsx b/gui/workflow_frontend/src/views/home/homeView.tsx index 4bfb826b..000633cc 100644 --- a/gui/workflow_frontend/src/views/home/homeView.tsx +++ b/gui/workflow_frontend/src/views/home/homeView.tsx @@ -27,7 +27,8 @@ import { VStack, } from '@chakra-ui/react'; import { CodeEditorModal } from './components/codeEditorModal'; -import { JUPYTER_BASE_URL, API_BASE_URL } from '../../config/urls'; +import { API_BASE_URL } from '../../config/urls'; +import { openJupyterTree } from '../../api/jupyterTenant'; import '@xyflow/react/dist/style.css'; import SideBoxArea from '../box/boxView'; import { CalculationNodeData, Project, FlowData } from './type'; @@ -134,7 +135,7 @@ const HomeView = () => { try { // Get project name const projectName = projects.find(p => p.id === selectedProject)?.name || selectedProject; - const jupyterUrl = `${JUPYTER_BASE_URL}/user/user1/lab/workspaces/auto-E/tree/codes/projects/${selectedProject}/workflow.py`; + const jupyterUrl = await openJupyterTree(`codes/projects/${selectedProject}/workflow.py`); // Create new tab addJupyterTab(selectedProject, projectName, jupyterUrl); diff --git a/gui/workflow_frontend/src/views/home/type.ts b/gui/workflow_frontend/src/views/home/type.ts index b7c9e131..d466201d 100644 --- a/gui/workflow_frontend/src/views/home/type.ts +++ b/gui/workflow_frontend/src/views/home/type.ts @@ -74,6 +74,8 @@ export interface CalculationNodeData { export type Visibility = "private" | "public"; +export type Tenant = "internal" | "hackathon"; + export type HpcTarget = "" | "riken" | "fugaku"; export interface ProjectOwner { @@ -113,6 +115,7 @@ export interface Project { description?: string; workflow_context?: Record; visibility: Visibility; + tenant?: Tenant; reference?: string; hpc_target?: HpcTarget; doi?: string;