diff --git a/CHANGELOG.md b/CHANGELOG.md index ae76a939..3194d821 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Server: + - Project-scoped scientific collaboration with private/internal/public discovery, + capability-based memberships and invitations, immutable scope storage, + fresh-schema scoped task identity, and authorized manifest-backed + cross-task artifact snapshots with persisted provenance. - runner-owned result storyboards: GREMLIN now declares logical Expected File Tree outputs and a trusted local Storyboard; ResultContext exposes only approved logical files while generic FileViewers and Files & diagnostics @@ -47,6 +51,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Documentation: - prune always-loaded Claude guidance and move rare PR/release procedures to linked `docs/agents/` references. - Server: + - Project Scope hardening: immutable task submitter IDs, read-only archived + scientific records with eligible artifact reuse, capability-scoped user + discovery, non-member attribution redaction, and fail-fast schema-epoch + validation with an explicit development-state reset contract. - deployment control: allow `build --server-only` to rebuild web/worker images without rebuilding runner images or invalidating prepared SIFs. - deployment control: allow `down --keep-gateway` to leave Nginx serving the maintenance page while application services remain stopped. - deployment control: add a standalone operator/developer guide for modes, preparation, SIF activation, safety, cache behavior, and recovery. diff --git a/server/PROJECT_SCOPE_AND_ARTIFACTS.md b/server/PROJECT_SCOPE_AND_ARTIFACTS.md new file mode 100644 index 00000000..6e97a9e3 --- /dev/null +++ b/server/PROJECT_SCOPE_AND_ARTIFACTS.md @@ -0,0 +1,112 @@ +# Project Scope, Storage, and Artifact References + +REvoCompute tasks belong to exactly one authoritative scope. Scope controls +authorization, discovery, storage, and artifact reuse; it is not presentation +metadata. + +```text +User + | + +-- Personal Scope + | `-- Task + | `-- Artifact + | + `-- Project Membership + `-- Project Scope + `-- Task + `-- manifest Artifact + `-- ArtifactReference + `-- downstream Task input snapshot +``` + +## Authorization + +Global account roles (`admin`, `user`, and `guest`) remain independent from +Project roles. Project roles are `owner`, `maintainer`, `contributor`, and +`viewer`; the collaboration service maps them to explicit capabilities. +Routes ask capability questions rather than interpreting role names. + +Visibility controls non-member discovery and reading: + +- `private`: members only; unknown callers receive the same response as a + missing Project. +- `internal`: authenticated non-members may discover the read-only Project + surface. +- `public`: anonymous callers may discover the read-only Project surface. + +Visibility does not grant membership, submission, artifact reuse, diagnostic +downloads, or future runner-policy eligibility. A viewer can read Project +results but cannot reuse artifacts. Task mutation requires `cancel_own_tasks` +or `cancel_project_tasks`, independently of read access. + +## Scoped storage + +Usernames and Project names are presentation metadata. Each user and Project +receives a persistent storage key with a readable initial prefix and random +opaque suffix. Renames never update that key or move result trees. + +New task paths are resolved only by `StorageResolver`: + +```text +results/ + users//tasks// + projects//tasks// +``` + +Input snapshots use the same scope hierarchy beneath the configured input +root. The physical hierarchy is an implementation detail and not an API. +Routes, workers, Docker/SLURM jobs, manifest finalization, archives, recovery, +and cleanup resolve paths from the persisted task scope. + +Project Scope ships with a fresh schema contract. Deployments adopting this +version rebuild the REvoCompute databases and storage roots; there is no old +task-layout resolver or username-based fallback. Every task row is complete at +creation and every path is derived from its immutable scope identity. + +This release is a new persistent-state epoch. Startup validates the user, task, +and collaboration schemas before serving work. Reusing an older or partial +schema fails with an operator-facing reset message; startup never alters, +backfills, guesses, or deletes persistent state. + +For the one-time development upgrade, stop REvoCompute, deliberately remove the +test-era user/task/collaboration databases and old workspace/results roots, +then start the release and recreate users and Projects. Ordinary restarts do +not perform this reset and must preserve the current databases and scoped +storage roots. + +Tasks persist two independent immutable identities: `scope_type` plus +`scope_id` determines where the task belongs, while `submitted_by_user_id` +records who submitted it. The optional username value is a historical display +snapshot only and is never an authorization primitive. + +## Artifact references + +An input may use `@/`. This is submission syntax +only. Before any physical lookup, the server loads the source task and checks +that the caller may reuse artifacts in the same Personal or Project scope. +Cross-user and cross-Project reuse is denied. + +When a Project is archived, eligible members may snapshot its finalized +manifest artifacts into their Personal scope. This narrow frozen-record rule +does not permit submission into the archived Project or Project-to-Project +sharing. + +The storage resolver then requires a finalized source task and an exact entry +in its authoritative result manifest. It rejects absolute paths, traversal, +empty path segments, symlinks, missing files, and content that no longer +matches the manifest hash or size. The caller never supplies or receives a +host, container, SLURM, or storage-key path. + +An authorized artifact is copied into the downstream task's immutable input +snapshot and appears to the runner as an ordinary local input. Provenance +records the downstream input, source task and scope, logical artifact path, +SHA-256, size, media type, and timestamp. + +```text +provenance propagates +permissions do not +``` + +Archiving or renaming the source scope therefore cannot mutate a submitted +downstream task, and access to the downstream task does not grant access to the +upstream task. diff --git a/server/README.md b/server/README.md index d1e2d1a2..71402e0d 100644 --- a/server/README.md +++ b/server/README.md @@ -5,6 +5,9 @@ and recovery procedures, see the [REvoCompute Deployment Control Guide](DEPLOYMENT_CONTROL_GUIDE.md). For the new-task/runtime-family adapter contract, see the [REvoCompute Operations and Task Adapter Guide](OPERATIONS_AND_TASK_ADAPTER_GUIDE.md). +For collaboration authorization, immutable scope identity, scoped storage, and +cross-task provenance, see +[Project Scope, Storage, and Artifact References](PROJECT_SCOPE_AND_ARTIFACTS.md). REvoCompute is a Flask + Celery service for multi-user protein computation. It supports Docker execution and production SLURM + Apptainer execution across @@ -31,8 +34,8 @@ The server loads the registry at startup via `CONFIG_DIR`. `gremlin` is always enabled; additional runners are gated by `ENABLED_TASKRUNNERS` in `.env`. Each runner container follows a standard contract (protocol v2): -- Sees one immutable task snapshot at `/mnt/revocompute//inputs/` - and task-owned results at `/mnt/revocompute//outputs/`. Concurrent +- Sees one immutable task snapshot at `/mnt/revocompute//inputs/` + and task-owned results at `/mnt/revocompute//outputs/`. Concurrent tasks have isolated host snapshots even though their virtual paths match. - Emits `REVODESIGN_STAGE:` on stdout for progress tracking - Is invoked as `run.sh -i /task.json -o `; the snapshot's @@ -671,6 +674,14 @@ Create a writable `AUTH_DIR` before the first start. The web process creates `${AUTH_DIR}/users.sqlite3` with the current schema. Existing databases must already match that schema; server setup does not migrate them. +Project Scope introduces a destructive development-state epoch transition. +For the one-time upgrade, stop REvoCompute, deliberately reset the test-era +user, task, and collaboration databases plus the old workspace/results roots, +then start the new release and recreate users and Projects. Startup validates +all three schemas and fails with reset instructions when old state is found; it +never migrates or deletes that state. An ordinary restart never resets current +databases or scoped storage. + ### Equivalent Docker Compose commands These commands are equivalent only after `users.sqlite3` contains an account. diff --git a/server/revocompute/app.py b/server/revocompute/app.py index 0487dcfb..b299e1eb 100644 --- a/server/revocompute/app.py +++ b/server/revocompute/app.py @@ -18,6 +18,7 @@ from revocompute.auth import _SECRET_KEY as _TOKEN_SIGNING_KEY # noqa: E402 from revocompute.auth import UserDatabase # noqa: E402 from revocompute.auth import _env_bool # noqa: E402 +from revocompute.collaboration import CollaborationDatabase # noqa: E402 from revocompute.config import ComputeConfig from revocompute.config import ensure_directories as _ensure_directories from revocompute.config import env_csv as _env_csv @@ -27,6 +28,7 @@ from revocompute.config import resolve_docker_user as _resolve_docker_user from revocompute.maintenance.tasks.result_cleanup import delete_task_artifacts as _delete_result_artifacts from revocompute.maintenance.tasks.result_cleanup import deleted_status_from_task as _result_deleted_status +from revocompute.storage import StorageResolver # noqa: E402 from revocompute.task_types import list_types as _list_task_types from sqlalchemy.exc import IntegrityError from werkzeug.utils import secure_filename @@ -57,6 +59,8 @@ "input-workspace.js", "input-workspace-rfdiffusion.js", "create-task.js", + "project.js", + "projects.js", "task-results.js", } @@ -115,6 +119,7 @@ def _add_security_headers(response): # --------------------------------------------------------------------------- _user_db = UserDatabase() app.config["user_db"] = _user_db +app.config["collaboration"] = CollaborationDatabase(os.path.join(CONFIG.server_dir, "collaboration.sqlite3")) ENABLE_REGISTER = _env_bool("ENABLE_REGISTER", False) # Force the auth cookie's Secure flag regardless of request.is_secure. @@ -188,6 +193,7 @@ def _add_security_headers(response): app.config["RESULT_DOWNLOAD_MODE"] = CONFIG.result_download_mode _ensure_directories(CONFIG.upload_folder, CONFIG.workspace_folder, CONFIG.results_folder) +app.config["storage_resolver"] = StorageResolver(CONFIG.results_folder, CONFIG.workspace_folder) # The authoritative task type registry is loaded by task_runtime's module-level # code. Startup fails if the configured registry is absent or invalid. @@ -344,8 +350,54 @@ def _is_admin_user() -> bool: def _task_access_allowed(task: dict[str, Any]) -> bool: if _is_admin_user(): return True - current_user = _current_username() or "" - return bool(current_user) and task.get("username") == current_user + user = g.get("current_user") + if task.get("scope_type") == "project": + return app.config["collaboration"].can_view_project( + int(task["scope_id"]), + int(user["id"]) if user else None, + authenticated=user is not None, + ) + if not user: + return False + return task.get("scope_type") == "personal" and str(task["scope_id"]) == str(user["id"]) + + +def _task_mutation_allowed(task: dict[str, Any]) -> bool: + """Authorize cancellation/deletion independently from read visibility.""" + if _is_admin_user(): + return True + user = g.get("current_user") + if not user: + return False + if task.get("scope_type") == "project": + store = app.config["collaboration"] + project_id = int(task["scope_id"]) + if str(task.get("submitted_by_user_id")) == str(user["id"]): + return store.can(project_id, int(user["id"]), "cancel_own_tasks") + return store.can(project_id, int(user["id"]), "cancel_project_tasks") + return task.get("scope_type") == "personal" and str(task["scope_id"]) == str(user["id"]) + + +def _task_full_results_allowed(task: dict[str, Any]) -> bool: + """Return whether the caller may read inputs, diagnostics, and archives.""" + if _is_admin_user(): + return True + user = g.get("current_user") + if not user: + return False + if task.get("scope_type") != "project" or not task.get("scope_id"): + return _task_access_allowed(task) + membership = app.config["collaboration"].get_membership(int(task["scope_id"]), int(user["id"])) + return bool(membership and app.config["collaboration"].can(int(task["scope_id"]), int(user["id"]), "view_results")) + + +def _task_artifact_access_allowed(task: dict[str, Any], artifact: dict[str, Any]) -> bool: + """Keep diagnostic/provenance files out of visibility-only surfaces.""" + if not _task_access_allowed(task): + return False + if _task_full_results_allowed(task): + return True + return artifact.get("role") not in {"diagnostic", "provenance"} def _task_access_denied(md5sum: str): @@ -361,10 +413,9 @@ def _task_access_denied(md5sum: str): ) -def _task_id_for_upload(content_md5: str, username: str | None) -> str: - # Keep task IDs owner-scoped so two users uploading the same FASTA never collide. - owner = username or "anonymous" - scoped_key = f"{owner}:{content_md5}" +def _task_id_for_upload(content_md5: str, scope_identity: str) -> str: + # Keep task IDs scope-specific so identical inputs in different scopes never collide. + scoped_key = f"{scope_identity}:{content_md5}" return hashlib.md5(scoped_key.encode("utf-8"), usedforsecurity=False).hexdigest() diff --git a/server/revocompute/auth.py b/server/revocompute/auth.py index f23fde57..bc259f14 100644 --- a/server/revocompute/auth.py +++ b/server/revocompute/auth.py @@ -16,6 +16,7 @@ import html import logging import os +import re import secrets import smtplib import time @@ -33,6 +34,7 @@ from revocompute.config import env_int as _env_int from revocompute.config import env_str as _env_str from revocompute.redis_util import get_redis +from revocompute.schema_epoch import require_current_schema from werkzeug.security import generate_password_hash # Pre-computed dummy hash used for constant-time comparison when a login @@ -93,9 +95,17 @@ sa.Column("registration_country", sa.String(8), nullable=True), sa.Column("token_version", sa.Integer, nullable=False, default=0), sa.Column("allow_gpu_use", sa.Boolean, nullable=False, default=False), + sa.Column("storage_key", sa.String(128), nullable=False, unique=True), ) +def _new_user_storage_key(username: str) -> str: + """Generate a readable storage key whose random suffix is immutable.""" + prefix = re.sub(r"[^A-Za-z0-9]+", "-", username).strip("-").lower()[:32] or "user" + suffix = secrets.token_urlsafe(6).lower().replace("_", "-").replace("=", "") + return f"{prefix}-{suffix}" + + def _get_user_db_path() -> str: """Resolve the user database path. @@ -133,20 +143,12 @@ def _initialize(self) -> None: conn.exec_driver_sql("PRAGMA busy_timeout=30000;") conn.exec_driver_sql("PRAGMA journal_mode=WAL;") conn.exec_driver_sql("PRAGMA synchronous=NORMAL;") + require_current_schema( + conn, + {"users": {column.name for column in _users_table.columns}}, + database_name="user database", + ) _metadata.create_all(conn, checkfirst=True) - # Migration: api_key_hash -> api_key_digest. The old werkzeug KDF - # hashes are one-way and NOT convertible to a digest — every - # existing API key becomes invalid by design and must be - # re-issued. The physical api_key_hash column, if present in an - # old DB, is left in place (harmless — the model no longer - # selects it). - columns = {row[1] for row in conn.exec_driver_sql("PRAGMA table_info(users)")} - if "api_key_digest" not in columns: - conn.exec_driver_sql("ALTER TABLE users ADD COLUMN api_key_digest VARCHAR(64)") - # Index the migrated column so validation stays a single - # lookup — same index name SQLAlchemy creates for new DBs - # (index=True), so fresh and migrated DBs match. - conn.exec_driver_sql("CREATE INDEX IF NOT EXISTS ix_users_api_key_digest ON users(api_key_digest)") try: os.chmod(self.path, 0o600) except OSError: @@ -191,6 +193,7 @@ def create_user( registration_ip=registration_ip, registration_country=registration_country, user_status=user_status, + storage_key=_new_user_storage_key(username), ) with self.engine.begin() as conn: result = conn.execute(stmt) diff --git a/server/revocompute/collaboration.py b/server/revocompute/collaboration.py new file mode 100644 index 00000000..74a84816 --- /dev/null +++ b/server/revocompute/collaboration.py @@ -0,0 +1,561 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""SQLite-backed Project collaboration and authorization primitives.""" + +from __future__ import annotations + +import os +import re +import secrets +import time +import unicodedata +from typing import Any + +import sqlalchemy as sa +from sqlalchemy.exc import IntegrityError + +from revocompute.schema_epoch import require_current_schema + +PROJECT_VISIBILITIES = frozenset({"private", "internal", "public"}) +PROJECT_ROLES = frozenset({"owner", "maintainer", "contributor", "viewer"}) +INVITATION_STATUSES = frozenset({"pending", "accepted", "declined", "revoked", "expired"}) +READ_CAPABILITIES = frozenset({"view_project", "view_tasks", "view_results"}) +ARCHIVED_MEMBER_CAPABILITIES = READ_CAPABILITIES | {"use_artifacts"} +ROLE_CAPABILITIES = { + "owner": frozenset( + { + *READ_CAPABILITIES, + "use_artifacts", + "submit_tasks", + "cancel_own_tasks", + "cancel_project_tasks", + "invite_members", + "manage_members", + "change_project_settings", + "delete_project", + "transfer_ownership", + } + ), + "maintainer": frozenset( + { + *READ_CAPABILITIES, + "use_artifacts", + "submit_tasks", + "cancel_own_tasks", + "cancel_project_tasks", + "invite_members", + "manage_members", + "change_project_settings", + } + ), + "contributor": frozenset({*READ_CAPABILITIES, "use_artifacts", "submit_tasks", "cancel_own_tasks"}), + "viewer": READ_CAPABILITIES, +} + + +def _slug(value: str) -> str: + value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode() + return (re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower() or "project")[:80] + + +def new_storage_key(display_name: str) -> str: + """Create a readable key whose identity is a random immutable suffix.""" + suffix = secrets.token_urlsafe(8).lower().replace("_", "-").replace("=", "") + return f"{_slug(display_name)[:32]}-{suffix}" + + +class CollaborationDatabase: + """Project store independent from ``UserDatabase``. + + User ids are opaque values. Callers verify account existence before invite. + """ + + def __init__(self, path: str): + self.path = os.path.abspath(path) + os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True) + self.engine = sa.create_engine( + f"sqlite:///{self.path}", future=True, connect_args={"check_same_thread": False, "timeout": 30} + ) + self.metadata = sa.MetaData() + self.projects = sa.Table( + "projects", + self.metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("name", sa.String(200), nullable=False), + sa.Column("slug", sa.String(200), nullable=False, unique=True), + sa.Column("description", sa.Text), + sa.Column("visibility", sa.String(16), nullable=False, server_default="private"), + sa.Column("storage_key", sa.String(128), nullable=False, unique=True), + sa.Column("created_at", sa.Float, nullable=False), + sa.Column("updated_at", sa.Float, nullable=False), + sa.Column("archived_at", sa.Float), + sa.CheckConstraint("visibility IN ('private','internal','public')", name="ck_project_visibility"), + ) + self.members = sa.Table( + "project_members", + self.metadata, + sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True), + sa.Column("user_id", sa.Integer, primary_key=True), + sa.Column("role", sa.String(16), nullable=False), + sa.Column("created_at", sa.Float, nullable=False), + sa.CheckConstraint("role IN ('owner','maintainer','contributor','viewer')", name="ck_member_role"), + ) + self.invitations = sa.Table( + "project_invitations", + self.metadata, + sa.Column("id", sa.Integer, primary_key=True), + sa.Column("project_id", sa.Integer, sa.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False), + sa.Column("invited_user_id", sa.Integer, nullable=False), + sa.Column("invited_by", sa.Integer, nullable=False), + sa.Column("proposed_role", sa.String(16), nullable=False), + sa.Column("status", sa.String(16), nullable=False), + sa.Column("created_at", sa.Float, nullable=False), + sa.Column("expires_at", sa.Float, nullable=False), + sa.Column("accepted_at", sa.Float), + sa.CheckConstraint("proposed_role IN ('maintainer','contributor','viewer')", name="ck_invite_role"), + sa.CheckConstraint( + "status IN ('pending','accepted','declined','revoked','expired')", name="ck_invite_status" + ), + ) + sa.Index("ix_project_members_user", self.members.c.user_id) + sa.Index( + "uq_project_owner", + self.members.c.project_id, + unique=True, + sqlite_where=self.members.c.role == "owner", + ) + sa.Index("ix_project_invitations_user_status", self.invitations.c.invited_user_id, self.invitations.c.status) + sa.Index( + "uq_pending_project_invite", + self.invitations.c.project_id, + self.invitations.c.invited_user_id, + unique=True, + sqlite_where=self.invitations.c.status == "pending", + ) + self._initialize() + + def _initialize(self) -> None: + with self.engine.begin() as conn: + conn.exec_driver_sql("PRAGMA busy_timeout=30000") + conn.exec_driver_sql("PRAGMA foreign_keys=ON") + conn.exec_driver_sql("PRAGMA journal_mode=WAL") + require_current_schema( + conn, + { + "projects": {column.name for column in self.projects.columns}, + "project_members": {column.name for column in self.members.columns}, + "project_invitations": {column.name for column in self.invitations.columns}, + }, + database_name="collaboration database", + ) + self.metadata.create_all(conn, checkfirst=True) + + @staticmethod + def _row(row: Any) -> dict[str, Any] | None: + return dict(row._mapping) if row is not None else None + + def _unique_slug(self, conn: Any, requested: str) -> str: + base, candidate, number = _slug(requested), _slug(requested), 2 + while conn.execute(sa.select(self.projects.c.id).where(self.projects.c.slug == candidate)).first(): + candidate, number = f"{base[:75]}-{number}", number + 1 + return candidate + + def create_project( + self, + owner_user_id: int, + name: str, + *, + slug: str | None = None, + description: str | None = None, + visibility: str = "private", + ) -> dict[str, Any]: + """Create the project and sole owner membership atomically.""" + name = str(name).strip() + if not name: + raise ValueError("project name cannot be empty") + if visibility not in PROJECT_VISIBILITIES: + raise ValueError("invalid project visibility") + now = time.time() + try: + with self.engine.begin() as conn: + result = conn.execute( + sa.insert(self.projects).values( + name=name, + slug=self._unique_slug(conn, slug or name), + description=description, + visibility=visibility, + storage_key=new_storage_key(name), + created_at=now, + updated_at=now, + ) + ) + project_id = result.inserted_primary_key[0] + conn.execute( + sa.insert(self.members).values( + project_id=project_id, user_id=int(owner_user_id), role="owner", created_at=now + ) + ) + except IntegrityError as exc: + raise ValueError("project identifier could not be allocated; retry creation") from exc + return self.get_project(project_id) # type: ignore[return-value] + + def get_project(self, project_id: int | str) -> dict[str, Any] | None: + with self.engine.connect() as conn: + return self._row(conn.execute(sa.select(self.projects).where(self.projects.c.id == project_id)).first()) + + def get_project_by_slug(self, slug: str) -> dict[str, Any] | None: + with self.engine.connect() as conn: + return self._row(conn.execute(sa.select(self.projects).where(self.projects.c.slug == slug)).first()) + + def list_projects(self, user_id: int | None, *, authenticated: bool = True) -> list[dict[str, Any]]: + with self.engine.connect() as conn: + rows = conn.execute(sa.select(self.projects)).all() + return [ + project + for row in rows + if (project := self._row(row)) + and self.can(project["id"], user_id, "view_project", authenticated=authenticated) + ] + + def update_project(self, project_id: int, **fields: Any) -> bool: + unknown = set(fields) - {"name", "description", "visibility"} + if unknown: + raise ValueError(f"unsupported project fields: {', '.join(sorted(unknown))}") + if "name" in fields: + fields["name"] = str(fields["name"]).strip() + if not fields["name"]: + raise ValueError("project name cannot be empty") + if "visibility" in fields and fields["visibility"] not in PROJECT_VISIBILITIES: + raise ValueError("invalid project visibility") + if not fields: + return False + fields["updated_at"] = time.time() + with self.engine.begin() as conn: + return ( + conn.execute( + sa.update(self.projects) + .where(self.projects.c.id == project_id, self.projects.c.archived_at.is_(None)) + .values(**fields) + ).rowcount + == 1 + ) + + def rename_project(self, project_id: int, name: str, *, slug: str | None = None) -> bool: + if slug is not None: + raise ValueError("project slug is immutable") + return self.update_project(project_id, name=name) + + def archive_project(self, project_id: int) -> bool: + now = time.time() + with self.engine.begin() as conn: + archived = conn.execute( + sa.update(self.projects) + .where(self.projects.c.id == project_id, self.projects.c.archived_at.is_(None)) + .values(archived_at=now, updated_at=now) + ).rowcount + if archived != 1: + return False + conn.execute( + sa.update(self.invitations) + .where(self.invitations.c.project_id == project_id, self.invitations.c.status == "pending") + .values(status="revoked") + ) + return True + + def list_members(self, project_id: int) -> list[dict[str, Any]]: + with self.engine.connect() as conn: + return [ + dict(row._mapping) + for row in conn.execute( + sa.select(self.members) + .where(self.members.c.project_id == project_id) + .order_by(self.members.c.created_at) + ) + ] + + def get_membership(self, project_id: int | str, user_id: int) -> dict[str, Any] | None: + with self.engine.connect() as conn: + return self._row( + conn.execute( + sa.select(self.members).where( + self.members.c.project_id == project_id, self.members.c.user_id == user_id + ) + ).first() + ) + + membership = get_membership + + def set_member_role(self, project_id: int, user_id: int, role: str) -> bool: + if role not in PROJECT_ROLES or role == "owner": + raise ValueError("owner changes require transfer_ownership") + with self.engine.begin() as conn: + return ( + conn.execute( + sa.update(self.members) + .where( + sa.exists( + sa.select(self.projects.c.id).where( + self.projects.c.id == self.members.c.project_id, + self.projects.c.archived_at.is_(None), + ) + ), + self.members.c.project_id == project_id, + self.members.c.user_id == user_id, + self.members.c.role != "owner", + ) + .values(role=role) + ).rowcount + == 1 + ) + + def transfer_ownership(self, project_id: int, current_owner_id: int, new_owner_id: int) -> bool: + with self.engine.begin() as conn: + active = conn.execute( + sa.select(self.projects.c.id).where( + self.projects.c.id == project_id, self.projects.c.archived_at.is_(None) + ) + ).scalar_one_or_none() + if active is None: + return False + current = conn.execute( + sa.select(self.members.c.role).where( + self.members.c.project_id == project_id, self.members.c.user_id == current_owner_id + ) + ).scalar_one_or_none() + target = conn.execute( + sa.select(self.members.c.role).where( + self.members.c.project_id == project_id, self.members.c.user_id == new_owner_id + ) + ).scalar_one_or_none() + if current != "owner" or target is None or current_owner_id == new_owner_id: + return False + conn.execute( + sa.update(self.members) + .where(self.members.c.project_id == project_id, self.members.c.user_id == current_owner_id) + .values(role="maintainer") + ) + conn.execute( + sa.update(self.members) + .where(self.members.c.project_id == project_id, self.members.c.user_id == new_owner_id) + .values(role="owner") + ) + return True + + def remove_member(self, project_id: int, user_id: int) -> bool: + with self.engine.begin() as conn: + return ( + conn.execute( + sa.delete(self.members).where( + sa.exists( + sa.select(self.projects.c.id).where( + self.projects.c.id == self.members.c.project_id, + self.projects.c.archived_at.is_(None), + ) + ), + self.members.c.project_id == project_id, + self.members.c.user_id == user_id, + self.members.c.role != "owner", + ) + ).rowcount + == 1 + ) + + def invite( + self, + project_id: int, + invited_user_id: int, + invited_by: int, + role: str = "viewer", + *, + expires_at: float | None = None, + ) -> dict[str, Any]: + if role not in PROJECT_ROLES or role == "owner": + raise ValueError("invalid invitation role") + now, expiry = time.time(), expires_at or time.time() + 7 * 86400 + if expiry <= now: + raise ValueError("invitation expiry must be in the future") + try: + with self.engine.begin() as conn: + active = conn.execute( + sa.select(self.projects.c.id).where( + self.projects.c.id == project_id, self.projects.c.archived_at.is_(None) + ) + ).scalar_one_or_none() + if active is None: + raise ValueError("project does not exist") + member = conn.execute( + sa.select(self.members.c.user_id).where( + self.members.c.project_id == project_id, self.members.c.user_id == invited_user_id + ) + ).first() + if member is not None: + raise ValueError("user is already a project member") + result = conn.execute( + sa.insert(self.invitations).values( + project_id=project_id, + invited_user_id=invited_user_id, + invited_by=invited_by, + proposed_role=role, + status="pending", + created_at=now, + expires_at=expiry, + ) + ) + invitation_id = result.inserted_primary_key[0] + except IntegrityError as exc: + raise ValueError("a pending invitation already exists") from exc + return self.get_invitation(invitation_id) # type: ignore[return-value] + + def get_invitation(self, invitation_id: int | str) -> dict[str, Any] | None: + with self.engine.connect() as conn: + return self._row( + conn.execute(sa.select(self.invitations).where(self.invitations.c.id == invitation_id)).first() + ) + + def _expire_pending_invitations(self, conn: Any, *criteria: Any) -> None: + conn.execute( + sa.update(self.invitations) + .where( + *criteria, + self.invitations.c.status == "pending", + self.invitations.c.expires_at <= time.time(), + ) + .values(status="expired") + ) + + def list_invitations(self, user_id: int, *, status: str = "pending") -> list[dict[str, Any]]: + if status not in INVITATION_STATUSES: + raise ValueError("invalid invitation status") + with self.engine.begin() as conn: + self._expire_pending_invitations(conn, self.invitations.c.invited_user_id == user_id) + return [ + dict(row._mapping) + for row in conn.execute( + sa.select(self.invitations) + .where(self.invitations.c.invited_user_id == user_id, self.invitations.c.status == status) + .order_by(self.invitations.c.created_at.desc()) + ) + ] + + def list_project_invitations( + self, + project_id: int | str, + *, + status: str | None = None, + ) -> list[dict[str, Any]]: + """Return a Project's invitations after refreshing expired pending rows.""" + if status is not None and status not in INVITATION_STATUSES: + raise ValueError("invalid invitation status") + with self.engine.begin() as conn: + self._expire_pending_invitations(conn, self.invitations.c.project_id == project_id) + query = sa.select(self.invitations).where(self.invitations.c.project_id == project_id) + if status is not None: + query = query.where(self.invitations.c.status == status) + return [ + dict(row._mapping) + for row in conn.execute( + query.order_by(self.invitations.c.created_at.desc()), + ) + ] + + def revoke_invitation(self, invitation_id: int | str) -> bool: + with self.engine.begin() as conn: + return ( + conn.execute( + sa.update(self.invitations) + .where(self.invitations.c.id == invitation_id, self.invitations.c.status == "pending") + .values(status="revoked") + ).rowcount + == 1 + ) + + def respond_invitation(self, invitation_id: int | str, user_id: int, accepted: bool) -> bool: + now = time.time() + with self.engine.begin() as conn: + invitation = conn.execute( + sa.select(self.invitations).where( + self.invitations.c.id == invitation_id, + self.invitations.c.invited_user_id == user_id, + self.invitations.c.status == "pending", + ) + ).first() + if invitation is None: + return False + project = conn.execute( + sa.select(self.projects.c.archived_at).where(self.projects.c.id == invitation.project_id) + ).first() + if project is None or project.archived_at is not None: + return False + if invitation.expires_at <= now: + conn.execute( + sa.update(self.invitations).where(self.invitations.c.id == invitation_id).values(status="expired") + ) + return False + conn.execute( + sa.update(self.invitations) + .where(self.invitations.c.id == invitation_id) + .values(status="accepted" if accepted else "declined", accepted_at=now if accepted else None) + ) + if accepted: + conn.execute( + sa.insert(self.members).values( + project_id=invitation.project_id, user_id=user_id, role=invitation.proposed_role, created_at=now + ) + ) + return True + + def can(self, project_id: int | str, user_id: int | None, capability: str, *, authenticated: bool = True) -> bool: + project = self.get_project(project_id) + if not project: + return False + membership = self.get_membership(project_id, user_id) if user_id is not None else None + if membership: + capabilities = ROLE_CAPABILITIES[membership["role"]] + if project["archived_at"] is not None: + capabilities = capabilities.intersection(ARCHIVED_MEMBER_CAPABILITIES) + return capability in capabilities + if capability not in READ_CAPABILITIES: + return False + return project["visibility"] == "public" or (project["visibility"] == "internal" and authenticated) + + def _project_is_active(self, project_id: int | str) -> bool: + project = self.get_project(project_id) + return bool(project and project["archived_at"] is None) + + def capabilities( + self, + project_id: int | str, + user_id: int | None, + *, + authenticated: bool = True, + ) -> list[str]: + """Return the principal's effective Project capabilities.""" + project = self.get_project(project_id) + if not project: + return [] + membership = self.get_membership(project_id, user_id) if user_id is not None else None + if membership: + capabilities = ROLE_CAPABILITIES[membership["role"]] + if project["archived_at"] is not None: + capabilities = capabilities.intersection(ARCHIVED_MEMBER_CAPABILITIES) + return sorted(capabilities) + may_read = project["visibility"] == "public" or (project["visibility"] == "internal" and authenticated) + return sorted(READ_CAPABILITIES) if may_read else [] + + def can_view_project(self, project_id: int | str, user_id: int | None = None, **kwargs: Any) -> bool: + return self.can(project_id, user_id, "view_project", **kwargs) + + def can_submit_task(self, project_id: int | str, user_id: int) -> bool: + return self.can(project_id, user_id, "submit_tasks") + + def can_use_artifact(self, project_id: int | str, user_id: int) -> bool: + return self.can(project_id, user_id, "use_artifacts") + + def can_manage_members(self, project_id: int | str, user_id: int) -> bool: + return self.can(project_id, user_id, "manage_members") + + +ProjectDatabase = CollaborationDatabase +CollaborationStore = CollaborationDatabase diff --git a/server/revocompute/db.py b/server/revocompute/db.py index 3525f27a..ac1519ac 100644 --- a/server/revocompute/db.py +++ b/server/revocompute/db.py @@ -12,6 +12,7 @@ from typing import Any from sqlalchemy import ( + CheckConstraint, Column, Float, Index, @@ -29,6 +30,8 @@ from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.exc import OperationalError +from revocompute.schema_epoch import require_current_schema + class TaskDatabase: """Minimal SQLite-based task tracker for compute jobs.""" @@ -68,7 +71,6 @@ def __init__(self, path: str): Column("md5sum", String(32), primary_key=True), Column("filename", String, nullable=False), Column("file_path", String, nullable=False), - Column("result_dir", String, nullable=False), Column("uploaded_at", Float, nullable=False), Column("started_at", Float), Column("finished_at", Float), @@ -88,13 +90,31 @@ def __init__(self, path: str): Column("slurm_job_id", String), Column("container_id", String), Column("workflow_state", Text), + Column("scope_type", String, nullable=False), + Column("scope_id", String, nullable=False), + Column("storage_key", String, nullable=False), + Column("submitted_by_user_id", Integer, nullable=False), + Column("artifact_provenance", Text, nullable=False, default="[]"), + CheckConstraint("scope_type IN ('personal','project')", name="ck_task_scope_type"), ) Index("idx_tasks_uploaded_at", self.tasks_table.c.uploaded_at) + Index( + "idx_tasks_scope", + self.tasks_table.c.scope_type, + self.tasks_table.c.scope_id, + self.tasks_table.c.uploaded_at, + ) + Index("idx_tasks_submitter", self.tasks_table.c.submitted_by_user_id, self.tasks_table.c.uploaded_at) self._initialize() def _initialize(self) -> None: with self.engine.begin() as conn: self._safe_apply_pragmas(conn) + require_current_schema( + conn, + {"tasks": {column.name for column in self.tasks_table.columns}}, + database_name="task database", + ) try: self.metadata.create_all(conn, checkfirst=True) except OperationalError as exc: @@ -104,12 +124,6 @@ def _initialize(self) -> None: if "already exists" not in str(exc).lower(): raise logging.warning("TaskDatabase metadata already present, skipping creation") - # create_all does not add columns to existing tables — backfill - # ones added after a table first shipped (idempotent). - existing = {row[1] for row in conn.exec_driver_sql("PRAGMA table_info(tasks)")} - for column in ("container_id", "workflow_state"): - if column not in existing: - conn.exec_driver_sql(f"ALTER TABLE tasks ADD COLUMN {column} VARCHAR") @staticmethod def _safe_apply_pragmas(conn) -> None: @@ -147,7 +161,13 @@ def _ensure_status(self, status: str) -> None: def _is_deleted_status(cls, status: Any) -> bool: return str(status or "").strip().lower() in cls.DELETED_STATUSES - def upsert_task(self, md5sum: str, **fields) -> None: + def upsert_task(self, task_id: str | None = None, **fields) -> None: + supplied_id = fields.pop("md5sum", None) + if task_id is not None and supplied_id is not None and task_id != supplied_id: + raise ValueError("Conflicting task ids") + md5sum = task_id or supplied_id + if not md5sum: + raise ValueError("Task id is required") if not fields: return status = fields.get("status") @@ -257,13 +277,13 @@ def list_tasks(self) -> list[dict]: rows = conn.execute(stmt).mappings().all() return [self._normalize_task_row(row) for row in rows] - def count_user_active_tasks(self, username: str) -> int: + def count_user_active_tasks(self, user_id: int) -> int: """Count pending, queued, and running tasks for a user.""" stmt = ( select(func.count()) .select_from(self.tasks_table) .where( - self.tasks_table.c.username == username, + self.tasks_table.c.submitted_by_user_id == user_id, self.tasks_table.c.status.in_(["pending", "queued", "running"]), ) ) diff --git a/server/revocompute/maintenance/tasks/result_cleanup.py b/server/revocompute/maintenance/tasks/result_cleanup.py index 19d75ef2..18ce5caa 100644 --- a/server/revocompute/maintenance/tasks/result_cleanup.py +++ b/server/revocompute/maintenance/tasks/result_cleanup.py @@ -18,6 +18,7 @@ from revocompute.config import ComputeConfig, env_float from revocompute.db import TaskDatabase from revocompute.maintenance.model import PeriodicTask +from revocompute.storage import StorageResolver _TASK_ID_PATTERN = re.compile(r"[a-fA-F0-9]{32}$") _TERMINAL_RESULT_STATUSES = {"finished", "failed", "cancelled"} @@ -51,9 +52,16 @@ def deleted_status_from_task(task: dict[str, Any]) -> str: def delete_task_artifacts(task: dict[str, Any], results_folder: str, workspace_folder: str | None = None) -> None: """Safely remove one task's result tree, archive cache, and input snapshot.""" - result_dir = task.get("result_dir") - if result_dir: - safe_result_dir = os.path.abspath(str(result_dir)) + resolver = StorageResolver( + workspace_dir=workspace_folder or os.path.join(os.path.dirname(results_folder), "workspaces"), + results_dir=results_folder, + ) + try: + safe_result_dir = resolver.get_task_root(task) + except ValueError: + logging.warning("Refusing to delete task with invalid storage identity: %s", task.get("md5sum")) + return + if safe_result_dir: if os.path.isdir(safe_result_dir): if safe_result_dir in {os.path.abspath(os.sep), os.path.abspath(os.path.expanduser("~"))}: logging.warning("Refusing to delete unsafe root-like directory: %s", safe_result_dir) @@ -66,13 +74,16 @@ def delete_task_artifacts(task: dict[str, Any], results_folder: str, workspace_f if not _TASK_ID_PATTERN.fullmatch(task_id): logging.warning("Refusing to delete zip for invalid task id: %s", task.get("md5sum")) return - zip_path = os.path.abspath(os.path.join(results_folder, f"{task_id}_results.zip")) + zip_path = resolver.get_archive_path(task) if _path_is_within(results_folder, zip_path) and os.path.exists(zip_path): os.remove(zip_path) if workspace_folder: - username = str(task.get("username") or "") - workspace_dir = os.path.abspath(os.path.join(workspace_folder, username, task_id)) + try: + workspace_dir = resolver.get_input_root(task) + except ValueError: + logging.warning("Refusing to delete invalid task input storage: %s", task_id) + return if _path_is_within(workspace_folder, workspace_dir) and os.path.isdir(workspace_dir): shutil.rmtree(workspace_dir, ignore_errors=True) diff --git a/server/revocompute/routes.py b/server/revocompute/routes.py index 1e624c2a..8437d8b7 100644 --- a/server/revocompute/routes.py +++ b/server/revocompute/routes.py @@ -48,7 +48,6 @@ TEMPLATE_IMAGE_DIR, _client_country, _client_ip, - _current_username, _delete_task_artifacts, _deleted_status_from_task, _is_admin_user, @@ -58,7 +57,10 @@ _revoke_celery_task, _task_access_allowed, _task_access_denied, + _task_artifact_access_allowed, + _task_full_results_allowed, _task_id_for_upload, + _task_mutation_allowed, _task_zip_download_name, app, ) @@ -85,6 +87,7 @@ from revocompute.input_validators import validate_input_file from revocompute.ratelimit import rate_limit from revocompute.resource_policy import GLOBAL_RESOURCE_KEYS, ResourceValidationError, normalize_resource_value +from revocompute.result_storyboard import ResultContractError, expected_file_tree, runner_root, storyboard_declaration from revocompute.schemas import ( AdminCreateUserRequest, AdminUpdateUserRequest, @@ -116,7 +119,6 @@ run_compute_task, task_store, ) -from revocompute.result_storyboard import ResultContractError, expected_file_tree, runner_root, storyboard_declaration from revocompute.task_types import get as get_task_type from revocompute.task_types import iter_capabilities, list_categories, list_types from revocompute.workspace_contracts import ( @@ -133,6 +135,345 @@ # --------------------------------------------------------------------------- +@app.route("/compute/projects", methods=["GET"]) +@login_required +def projects_page(): + return render_template("projects.html") + + +@app.route("/compute/projects/", methods=["GET"]) +@optional_user +def project_page(project_id: str): + if not _project_access(project_id, "view_project"): + abort(404) + return render_template("project.html", project_id=project_id) + + +def _authentication_required(): + return jsonify({"error": "Authentication required"}), 401 + + +def _project_access(project_id: str, capability: str): + store = current_app.config["collaboration"] + user = g.get("current_user") + authenticated = user is not None + project = store.get_project(project_id) + if not project or not store.can( + project_id, int(user["id"]) if user else None, capability, authenticated=authenticated + ): + return None + return project + + +@app.route("/compute/api/projects", methods=["GET", "POST"]) +@optional_user +def projects_api(): + store = current_app.config["collaboration"] + if request.method == "GET": + user = g.get("current_user") + uid = int(user["id"]) if user else None + projects = store.list_projects(uid, authenticated=user is not None) + capability = request.args.get("capability") + if capability: + projects = [project for project in projects if user and store.can(project["id"], uid, capability)] + all_tasks = task_store.list_tasks() + projects = [ + { + **project, + "membership_role": ( + membership["role"] if user and (membership := store.get_membership(project["id"], uid)) else None + ), + "member_count": len(store.list_members(project["id"])), + "task_count": sum( + task.get("scope_type") == "project" + and str(task.get("scope_id")) == str(project["id"]) + and not _is_deleted_status(task.get("status")) + for task in all_tasks + ), + } + for project in projects + ] + return jsonify({"projects": projects}) + if not g.get("current_user"): + return _authentication_required() + if blocked := require_bearer_auth(): + return blocked + payload = request.get_json(silent=True) or {} + try: + project = store.create_project( + int(g.current_user["id"]), + str(payload.get("name", "")), + description=str(payload.get("description", "")), + visibility=str(payload.get("visibility", "private")), + ) + except (IntegrityError, ValueError) as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(project), 201 + + +@app.route("/compute/api/projects/", methods=["GET", "PATCH", "DELETE"]) +@optional_user +def project_api(project_id: str): + store = current_app.config["collaboration"] + if request.method == "GET": + project = _project_access(project_id, "view_project") + if not project: + return jsonify({"error": "Project not found"}), 404 + user = g.get("current_user") + membership = store.get_membership(project_id, int(user["id"])) if user else None + task_count = sum( + task.get("scope_type") == "project" + and str(task.get("scope_id")) == str(project_id) + and not _is_deleted_status(task.get("status")) + for task in task_store.list_tasks() + ) + return jsonify( + { + **project, + "membership_role": membership.get("role") if membership else None, + "member_count": len(store.list_members(project_id)), + "task_count": task_count, + "capabilities": store.capabilities( + project_id, int(user["id"]) if user else None, authenticated=user is not None + ), + } + ) + if not g.get("current_user"): + return _authentication_required() + if blocked := require_bearer_auth(): + return blocked + capability = "delete_project" if request.method == "DELETE" else "change_project_settings" + if not _project_access(project_id, capability): + return jsonify({"error": "Project not found"}), 404 + if request.method == "DELETE": + return ( + (jsonify({"status": "archived"}), 200) + if store.archive_project(project_id) + else (jsonify({"error": "Project not found"}), 404) + ) + payload = request.get_json(silent=True) or {} + try: + store.update_project(project_id, **payload) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(store.get_project(project_id)) + + +@app.route("/compute/api/projects//members", methods=["GET"]) +@login_required +def project_members_api(project_id: str): + store = current_app.config["collaboration"] + if not store.get_membership(project_id, int(g.current_user["id"])): + return jsonify({"error": "Project not found"}), 404 + users = current_app.config["user_db"] + payload = [] + for member in store.list_members(project_id): + user = users.get_user(member["user_id"]) + payload.append({**member, "username": user.get("username") if user else "Deleted user"}) + return jsonify({"members": payload}) + + +@app.route("/compute/api/projects//members/", methods=["PATCH", "DELETE"]) +@login_required +def project_member_api(project_id: str, user_id: int): + if blocked := require_bearer_auth(): + return blocked + store = current_app.config["collaboration"] + if not store.can_manage_members(project_id, int(g.current_user["id"])): + return jsonify({"error": "Forbidden"}), 403 + if request.method == "DELETE": + if not store.remove_member(project_id, user_id): + return jsonify({"error": "Owner cannot be removed or member was not found"}), 409 + return "", 204 + payload = request.get_json(silent=True) or {} + try: + updated = store.set_member_role(project_id, user_id, str(payload.get("role", ""))) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return ( + (jsonify(store.get_membership(project_id, user_id)), 200) + if updated + else (jsonify({"error": "Member not found"}), 404) + ) + + +@app.route("/compute/api/projects//transfer-ownership", methods=["POST"]) +@login_required +def project_transfer_ownership_api(project_id: str): + if blocked := require_bearer_auth(): + return blocked + store = current_app.config["collaboration"] + uid = int(g.current_user["id"]) + if not store.can(project_id, uid, "transfer_ownership"): + return jsonify({"error": "Forbidden"}), 403 + payload = request.get_json(silent=True) or {} + try: + target = int(payload["user_id"]) + except (KeyError, TypeError, ValueError): + return jsonify({"error": "user_id is required"}), 400 + if not store.transfer_ownership(project_id, uid, target): + return jsonify({"error": "Ownership transfer requires an existing member"}), 409 + return jsonify({"status": "transferred"}) + + +@app.route("/compute/api/projects//invitations", methods=["POST"]) +@login_required +def project_invite_api(project_id: str): + if blocked := require_bearer_auth(): + return blocked + store = current_app.config["collaboration"] + uid = int(g.current_user["id"]) + if not store.can(project_id, uid, "invite_members"): + return jsonify({"error": "Forbidden"}), 403 + payload = request.get_json(silent=True) or {} + user = None + if payload.get("username"): + user = current_app.config["user_db"].get_user_by_username(str(payload["username"])) + elif payload.get("user_id") is not None: + try: + user = current_app.config["user_db"].get_user(int(payload["user_id"])) + except (TypeError, ValueError): + user = None + if not user or user.get("deleted"): + return jsonify({"error": "Invited user was not found"}), 404 + try: + invitation = store.invite(project_id, int(user["id"]), uid, str(payload.get("role", "viewer"))) + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + return jsonify(invitation), 201 + + +@app.route("/compute/api/projects//invitations", methods=["GET"]) +@login_required +def project_invitations_api(project_id: str): + store = current_app.config["collaboration"] + if not store.can_manage_members(project_id, int(g.current_user["id"])): + return jsonify({"error": "Forbidden"}), 403 + users = current_app.config["user_db"] + invitations = [] + for invitation in store.list_project_invitations(project_id): + invited = users.get_user(invitation["invited_user_id"]) + invitations.append({**invitation, "invited_username": invited.get("username") if invited else "Deleted user"}) + return jsonify({"invitations": invitations}) + + +@app.route("/compute/api/projects//invitations/", methods=["DELETE"]) +@login_required +def project_invitation_revoke_api(project_id: str, invitation_id: str): + if blocked := require_bearer_auth(): + return blocked + store = current_app.config["collaboration"] + invitation = store.get_invitation(invitation_id) + if ( + not invitation + or str(invitation["project_id"]) != str(project_id) + or not store.can_manage_members(project_id, int(g.current_user["id"])) + ): + return jsonify({"error": "Invitation not found"}), 404 + return ( + ("", 204) + if store.revoke_invitation(invitation_id) + else (jsonify({"error": "Invitation is no longer pending"}), 409) + ) + + +@app.route("/compute/api/invitations", methods=["GET"]) +@login_required +def invitations_api(): + store = current_app.config["collaboration"] + invitations = [] + for invitation in store.list_invitations(int(g.current_user["id"])): + project = store.get_project(invitation["project_id"]) + invitations.append({**invitation, "project_name": project.get("name") if project else "Project"}) + return jsonify({"invitations": invitations}) + + +@app.route("/compute/api/invitations/", methods=["POST"]) +@login_required +def invitation_response_api(invitation_id: str): + if blocked := require_bearer_auth(): + return blocked + payload = request.get_json(silent=True) or {} + action = payload.get("action") + if action is None and isinstance(payload.get("accept"), bool): + action = "accept" if payload["accept"] else "decline" + if action not in {"accept", "decline"}: + return jsonify({"error": "action must be accept or decline"}), 400 + accepted = action == "accept" + ok = current_app.config["collaboration"].respond_invitation(invitation_id, int(g.current_user["id"]), accepted) + return ( + (jsonify({"status": "accepted" if accepted else "declined"}), 200) + if ok + else (jsonify({"error": "Invalid or expired invitation"}), 404) + ) + + +@app.route("/compute/api/projects//archive", methods=["POST"]) +@login_required +def project_archive_api(project_id: str): + if blocked := require_bearer_auth(): + return blocked + store = current_app.config["collaboration"] + if not store.can(project_id, int(g.current_user["id"]), "delete_project"): + return jsonify({"error": "Project not found"}), 404 + return ( + (jsonify({"status": "archived"}), 200) + if store.archive_project(project_id) + else (jsonify({"error": "Project not found"}), 404) + ) + + +@app.route("/compute/api/projects//users/search", methods=["GET"]) +@login_required +def project_users_search_api(project_id: str): + store = current_app.config["collaboration"] + if not store.can(project_id, int(g.current_user["id"]), "invite_members"): + return jsonify({"error": "Forbidden"}), 403 + query = request.args.get("q", "").strip().casefold() + if len(query) < 2: + return jsonify({"users": []}) + excluded_user_ids = {int(member["user_id"]) for member in store.list_members(project_id)} + excluded_user_ids.update( + int(invitation["invited_user_id"]) + for invitation in store.list_project_invitations(project_id, status="pending") + ) + users = [ + { + "id": user["id"], + "username": user["username"], + "display_name": user.get("full_name") or user["username"], + } + for user in current_app.config["user_db"].list_users() + if int(user["id"]) not in excluded_user_ids + if query in str(user.get("username") or "").casefold() or query in str(user.get("full_name") or "").casefold() + ][:20] + return jsonify({"users": users}) + + +@app.route("/compute/api/projects//tasks", methods=["GET"]) +@optional_user +def project_tasks_api(project_id: str): + if not _project_access(project_id, "view_tasks"): + return jsonify({"error": "Project not found"}), 404 + user = g.get("current_user") + is_member = bool(user and current_app.config["collaboration"].get_membership(project_id, int(user["id"]))) + reveal_submitter = _is_admin_user() or is_member + tasks = [ + { + "md5sum": task["md5sum"], + "filename": task["filename"], + "task_type": task["task_type"], + "status": task["status"], + "uploaded_at": task["uploaded_at"], + "submitted_by": task.get("username") if reveal_submitter else None, + } + for task in task_store.list_tasks() + if task.get("scope_type") == "project" and str(task.get("scope_id")) == str(project_id) + and not _is_deleted_status(task.get("status")) + ] + return jsonify({"tasks": tasks}) + + @app.route("/", methods=["GET"]) def index_page(): return render_template("index.html") @@ -509,23 +850,24 @@ def _safe_input_relative_path(raw_path: str) -> str | None: return "/".join(safe_parts) -def _validate_input_uploads(task_type: str = "gremlin"): +def _validate_input_uploads(task_type: str = "gremlin", artifact_reference_count: int = 0): """Return validated uploads with safe relative paths, or an HTTP error.""" try: tt, _ = _get_task_type(task_type) except KeyError: return None, (jsonify({"error": f"Unknown task type: {task_type}"}), 400) if "files" not in request.files and "file" not in request.files: - if tt.min_input_files == 0: + if artifact_reference_count >= tt.min_input_files: return [], None return None, (jsonify({"error": "No file part"}), 400) uploads = request.files.getlist("files") or request.files.getlist("file") uploads = [uploaded for uploaded in uploads if uploaded.filename] - if len(uploads) < tt.min_input_files: + total_inputs = len(uploads) + artifact_reference_count + if total_inputs < tt.min_input_files: return None, (jsonify({"error": "No selected file"}), 400) - if not tt.allow_multiple_inputs and len(uploads) != 1: + if not tt.allow_multiple_inputs and total_inputs != 1: return None, (jsonify({"error": f"{tt.display_name} accepts exactly one input file"}), 400) - if len(uploads) > tt.max_input_files: + if total_inputs > tt.max_input_files: return None, (jsonify({"error": f"At most {tt.max_input_files} input files are allowed"}), 400) submitted_paths = request.form.getlist("input_paths") accepted = tuple(extension.lower() for extension in (tt.input_extensions or (tt.input_extension,))) @@ -550,7 +892,12 @@ def _validate_input_uploads(task_type: str = "gremlin"): def _save_uploaded_inputs( - uploads: list[tuple[Any, str]], task_type: str, params: dict[str, Any] + uploads: list[tuple[Any, str]], + task_type: str, + params: dict[str, Any], + *, + referenced_inputs: list[dict[str, Any]] | None = None, + scope_identity: str, ) -> tuple[str, list[dict[str, Any]], dict[str, str]]: """Persist content-addressed blobs and derive an owner-scoped task ID.""" metadata = _request_metadata() @@ -577,6 +924,7 @@ def _save_uploaded_inputs( "blob_path": blob_path, } ) + saved.extend(referenced_inputs or []) identity = json.dumps( { "task_type": task_type, @@ -587,7 +935,117 @@ def _save_uploaded_inputs( sort_keys=True, ) content_id = hashlib.sha256(identity.encode("utf-8")).hexdigest() - return _task_id_for_upload(content_id, metadata["username"]), saved, metadata + return _task_id_for_upload(content_id, scope_identity), saved, metadata + + +_ARTIFACT_REFERENCE_PATTERN = re.compile(r"@([a-fA-F0-9]{32})/(.+)") + + +def _artifact_reference_values() -> list[str]: + references: list[str] = [] + for value in request.form.getlist("artifact_references"): + references.extend(line.strip() for line in str(value).splitlines() if line.strip()) + return references + + +def _resolve_submission_scope(submission: TaskSubmissionRequest) -> dict[str, Any]: + user = g.current_user + if submission.scope_type == "personal": + storage_key = user.get("storage_key") + if not storage_key: + raise RuntimeError("User storage identity is unavailable") + return {"scope_type": "personal", "scope_id": str(user["id"]), "storage_key": storage_key} + project = current_app.config["collaboration"].get_project(submission.scope_id) + if not project or not current_app.config["collaboration"].can_submit_task(project["id"], user["id"]): + raise PermissionError("Project is unavailable or does not accept submissions") + return {"scope_type": "project", "scope_id": str(project["id"]), "storage_key": project["storage_key"]} + + +def _can_reuse_source_task(source: dict[str, Any], destination_scope: dict[str, Any]) -> bool: + user = g.current_user + if source.get("scope_type") == "project": + source_project = str(source.get("scope_id") or "") + if not source_project or not current_app.config["collaboration"].can_use_artifact( + int(source_project), int(user["id"]) + ): + return False + if destination_scope["scope_type"] == "project": + return source_project == destination_scope["scope_id"] + project = current_app.config["collaboration"].get_project(int(source_project)) + return destination_scope["scope_type"] == "personal" and bool(project and project["archived_at"] is not None) + if destination_scope["scope_type"] != "personal": + return False + return source.get("scope_type") == "personal" and str(source["scope_id"]) == str(user["id"]) + + +def _resolve_artifact_inputs( + references: list[str], task_type: Any, destination_scope: dict[str, Any], uploaded_count: int +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + accepted = tuple(extension.lower() for extension in (task_type.input_extensions or (task_type.input_extension,))) + primary = tuple( + extension.lower() for extension in (task_type.primary_input_extensions or (task_type.input_extension,)) + ) + saved: list[dict[str, Any]] = [] + provenance: list[dict[str, Any]] = [] + used_paths: set[str] = set() + for index, expression in enumerate(references): + match = _ARTIFACT_REFERENCE_PATTERN.fullmatch(expression) + if not match: + raise ValueError("Invalid artifact reference") + source_task_id, logical_path = match.groups() + source = task_store.get_task(source_task_id.lower()) + # Authorization intentionally precedes manifest or filesystem access. + if ( + source is None + or source.get("status") != "finished" + or not _can_reuse_source_task(source, destination_scope) + ): + raise PermissionError("Artifact reference is unavailable") + resolved = current_app.config["storage_resolver"].resolve_artifact(source, logical_path) + if resolved is None: + raise ValueError("Artifact reference is unavailable") + logical_extension = os.path.splitext(logical_path)[1].lower() + allowed = primary if uploaded_count == 0 and index == 0 else accepted + if logical_extension not in allowed: + raise ValueError("Artifact type is incompatible with this task input") + blob_path = _safe_join(app.config["UPLOAD_FOLDER"], f"{resolved['sha256']}.upload") + if not os.path.exists(blob_path): + temporary = _safe_join(app.config["UPLOAD_FOLDER"], f".tmp_artifact_{os.urandom(8).hex()}") + shutil.copyfile(resolved["physical_path"], temporary) + try: + os.replace(temporary, blob_path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + relative_path = secure_filename(os.path.basename(logical_path)) + if not relative_path or relative_path in used_paths: + relative_path = f"{source_task_id[:8]}-{relative_path or 'artifact'}" + if relative_path in used_paths: + raise ValueError("Artifact references produce duplicate input paths") + used_paths.add(relative_path) + saved.append( + { + "original_name": relative_path, + "relative_path": relative_path, + "hash": resolved["sha256"], + "blob_path": blob_path, + "artifact_reference": expression, + } + ) + provenance.append( + { + "input_name": relative_path, + "source_task_id": source["md5sum"], + "source_artifact_path": resolved["path"], + "source_scope_type": source["scope_type"], + "source_scope_id": source.get("scope_id"), + "sha256": resolved["sha256"], + "size": resolved["size"], + "media_type": resolved.get("media_type"), + "created_at": time.time(), + } + ) + return saved, provenance def _existing_upload_response(existing_task: dict[str, Any] | None, md5sum: str): @@ -613,11 +1071,19 @@ def _prepare_task_record( metadata: dict[str, str], task_type: str = "gremlin", input_form: dict[str, Any] | None = None, + task_scope: dict[str, Any] | None = None, + artifact_provenance: list[dict[str, Any]] | None = None, ) -> dict[str, Any]: - workspace_key = str(metadata["username"]) - if not _WORKSPACE_KEY_PATTERN.fullmatch(workspace_key): - raise ValueError("Username cannot be represented safely in the workspace path") - workspace_dir = _safe_join(app.config["WORKSPACE_FOLDER"], workspace_key, md5sum) + if not task_scope: + raise ValueError("Task scope is required") + task_identity = { + "md5sum": md5sum, + "scope_type": task_scope["scope_type"], + "scope_id": task_scope["scope_id"], + "storage_key": task_scope["storage_key"], + } + resolver = app.config["storage_resolver"] + workspace_dir = resolver.get_input_root(task_identity) if os.path.exists(workspace_dir): shutil.rmtree(workspace_dir) snapshot_root = _safe_join(workspace_dir, "inputs") @@ -628,11 +1094,11 @@ def _prepare_task_record( shutil.copyfile(item["blob_path"], destination) os.chmod(destination, 0o440) - result_dir = _safe_join(app.config["RESULTS_FOLDER"], md5sum) + result_dir = resolver.get_output_root(task_identity) if os.path.exists(result_dir): shutil.rmtree(result_dir) os.makedirs(result_dir, exist_ok=True) - zip_path = _task_zip_path(md5sum) + zip_path = resolver.get_archive_path(task_identity) if os.path.exists(zip_path): os.remove(zip_path) @@ -640,7 +1106,6 @@ def _prepare_task_record( return { "filename": primary["relative_path"] if primary else "Generated structure", "file_path": primary["blob_path"] if primary else "", - "result_dir": result_dir, "uploaded_at": time.time(), "started_at": None, "finished_at": None, @@ -649,12 +1114,15 @@ def _prepare_task_record( "source_ip": metadata["ip"], "user_agent": metadata["user_agent"], "username": metadata["username"], + "submitted_by_user_id": int(g.current_user["id"]), "request_headers": metadata["headers_json"], "local_user": _local_user_identity(), "celery_task_id": None, "run_stage": None, "task_type": task_type, "input_form": json.dumps(input_form) if input_form else None, + **task_identity, + "artifact_provenance": json.dumps(artifact_provenance or [], sort_keys=True), } @@ -707,6 +1175,8 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra # Parse flat form data ("params[key]=value") into nested dict raw_form = request.form.to_dict(flat=True) + artifact_references = _artifact_reference_values() + raw_form.pop("artifact_references", None) form_data: dict[str, Any] = {} nested_params: dict[str, Any] = {} for key, value in raw_form.items(): @@ -762,6 +1232,13 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra return jsonify({"error": "Region-owned parameters must be submitted through workspace state"}), 400 submission.params.update(normalized_regions["params"]) coerced_params = submission.coerce_params() + try: + task_scope = _resolve_submission_scope(submission) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 + except RuntimeError: + logging.exception("Authenticated user has no immutable storage identity") + return jsonify({"error": "Account storage is not initialized; contact an administrator."}), 503 managedb = current_app.config.get("manage_db") if managedb is not None: @@ -796,10 +1273,29 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra logging.error("Resource policy rejected submission for %s: %s", task_type, exc) return jsonify({"error": "This task type has an invalid resource policy; contact an administrator."}), 503 - uploaded_inputs, upload_error = _validate_input_uploads(task_type) + uploaded_inputs, upload_error = _validate_input_uploads(task_type, len(artifact_references)) if upload_error is not None: return upload_error - md5sum, saved_inputs, metadata = _save_uploaded_inputs(uploaded_inputs, task_type, coerced_params) + try: + referenced_inputs, artifact_provenance = _resolve_artifact_inputs( + artifact_references, tt, task_scope, len(uploaded_inputs) + ) + except PermissionError as exc: + return jsonify({"error": str(exc)}), 403 + except ValueError as exc: + return jsonify({"error": str(exc)}), 400 + uploaded_paths = {path for _, path in uploaded_inputs} + if uploaded_paths & {item["relative_path"] for item in referenced_inputs}: + return jsonify({"error": "Uploaded files and artifact references have duplicate input paths"}), 400 + md5sum, saved_inputs, metadata = _save_uploaded_inputs( + uploaded_inputs, + task_type, + coerced_params, + referenced_inputs=referenced_inputs, + scope_identity=f"{task_scope['scope_type']}:{task_scope['scope_id']}", + ) + for record in artifact_provenance: + record["downstream_task_id"] = md5sum if normalized_regions is not None: try: validate_rfdiffusion_structure( @@ -808,9 +1304,9 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra ) except WorkspaceValidationError as exc: return jsonify({"error": str(exc)}), 400 - workspace_key = str(metadata["username"]) + workspace_key = task_scope["storage_key"] if not _WORKSPACE_KEY_PATTERN.fullmatch(workspace_key): - return jsonify({"error": "Username cannot be represented safely in a workspace path"}), 400 + return jsonify({"error": "Scope storage identity is invalid"}), 400 existing_task = task_store.get_task(md5sum) if existing_response := _existing_upload_response(existing_task, md5sum): @@ -820,7 +1316,7 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra # Celery/Docker queue, not the HTTP layer. Raise MAX_ACTIVE_TASKS_PER_USER # if users routinely hit it with legitimate batch work. MAX_ACTIVE_TASKS_PER_USER = 5 - if task_store.count_user_active_tasks(metadata["username"]) >= MAX_ACTIVE_TASKS_PER_USER: + if task_store.count_user_active_tasks(int(g.current_user["id"])) >= MAX_ACTIVE_TASKS_PER_USER: return ( jsonify( { @@ -834,7 +1330,8 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra # Build entities — one list for files and params together. entities: list[dict[str, Any]] = [] - snapshot_root = _safe_join(app.config["WORKSPACE_FOLDER"], workspace_key, md5sum, "inputs") + scoped_task = {"md5sum": md5sum, **task_scope} + snapshot_root = _safe_join(app.config["storage_resolver"].get_input_root(scoped_task), "inputs") virtual_root = f"/mnt/revocompute/{workspace_key}" for index, item in enumerate(saved_inputs): entities.append( @@ -876,6 +1373,8 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra "resource_policy": resource_policy.public_dict() if resource_policy is not None else None, "resource_policies": {name: policy.public_dict() for name, policy in resource_policies.items()}, "workspace": workspace_payload, + "scope": {"type": task_scope["scope_type"], "id": task_scope["scope_id"]}, + "artifact_provenance": artifact_provenance, } # Runner protocol v2: the immutable snapshot carries task.json — the @@ -902,6 +1401,8 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra metadata, task_type=task_type, input_form=input_form, + task_scope=task_scope, + artifact_provenance=artifact_provenance, ) # The manifest lands inside the snapshot AFTER _prepare_task_record has # created it (and copied the input files into it). @@ -940,7 +1441,7 @@ def upload_file(): # skipcq: PY-R1000 -- route validation branches form one tra @app.route("/compute/api/running/", methods=["GET"]) -@login_required +@optional_user def run_gremlin(md5sum): md5sum = _normalize_task_id(md5sum) if md5sum is None: @@ -955,8 +1456,9 @@ def run_gremlin(md5sum): if status == "finished": return jsonify({"status": "finished", "md5sum": md5sum}), 200 if status == "failed": + error = _sanitize_task_error(task, task.get("error")) if _task_full_results_allowed(task) else "Task failed" return ( - jsonify({"status": "failed", "md5sum": md5sum, "error": _sanitize_task_error(task, task.get("error"))}), + jsonify({"status": "failed", "md5sum": md5sum, "error": error}), 404, ) if status in ("running", "queued"): @@ -981,7 +1483,7 @@ def run_gremlin(md5sum): @app.route("/compute/api/results/", methods=["GET"]) -@login_required +@optional_user def get_results(md5sum): md5sum = _normalize_task_id(md5sum) if md5sum is None: @@ -995,22 +1497,40 @@ def get_results(md5sum): if task["status"] not in {"finished", "failed"}: return redirect(f"/compute/api/running/{md5sum}", code=302) - manifest_path = _safe_join(task["result_dir"], "manifest.json") + try: + manifest_path = current_app.config["storage_resolver"].get_manifest_path(task) + except ValueError: + return jsonify({"status": "error", "md5sum": md5sum, "message": "result manifest not found"}), 404 try: with open(manifest_path, encoding="utf-8") as handle: manifest = json.load(handle) - except (OSError, json.JSONDecodeError): + except (OSError, ValueError, json.JSONDecodeError): return jsonify({"status": "error", "md5sum": md5sum, "message": "result manifest not found"}), 404 archive_ready = os.path.isfile(_task_zip_path(task)) payload = dict(manifest) + full_results = _task_full_results_allowed(task) + if not full_results: + payload["artifacts"] = [ + artifact for artifact in payload.get("artifacts", []) if _task_artifact_access_allowed(task, artifact) + ] + visible_paths = {artifact["path"] for artifact in payload["artifacts"]} + visible_views = [] + for view in payload.get("views", []): + sources = { + name: [path for path in paths if path in visible_paths] + for name, paths in view.get("sources", {}).items() + } + if any(sources.values()): + visible_views.append({**view, "sources": sources}) + payload["views"] = visible_views payload.update( { "status": task["status"], "archive": { - "ready": archive_ready, - "request_url": f"/compute/api/results/{md5sum}/archive", - "download_url": f"/compute/api/download/{md5sum}" if archive_ready else None, + "ready": archive_ready and full_results, + "request_url": f"/compute/api/results/{md5sum}/archive" if full_results else None, + "download_url": f"/compute/api/download/{md5sum}" if archive_ready and full_results else None, }, } ) @@ -1032,6 +1552,7 @@ def get_results(md5sum): "url": f"/compute/api/results/{md5sum}/files/{file_id}?index={index}", } for index, artifact in enumerate(files) + if full_results or _task_artifact_access_allowed(task, artifact) ] payload["result"] = {"files": logical_files} if payload.get("storyboard"): @@ -1042,7 +1563,7 @@ def get_results(md5sum): @app.route("/compute/api/results//files/", methods=["GET"]) -@login_required +@optional_user def get_result_logical_file(md5sum: str, file_id: str): """Serve a single Expected File Tree identity, never a guessed path.""" normalized = _normalize_task_id(md5sum) @@ -1052,7 +1573,7 @@ def get_result_logical_file(md5sum: str, file_id: str): if task is None or not _task_access_allowed(task): return jsonify({"error": "Result file not found"}), 404 try: - with open(_safe_join(task["result_dir"], "manifest.json"), encoding="utf-8") as handle: + with open(current_app.config["storage_resolver"].get_manifest_path(task), encoding="utf-8") as handle: files = json.load(handle).get("result", {}).get("files", {}).get(file_id, []) except (OSError, json.JSONDecodeError): files = [] @@ -1066,7 +1587,7 @@ def get_result_logical_file(md5sum: str, file_id: str): @app.route("/compute/api/results//storyboard/", methods=["GET"]) -@login_required +@optional_user def get_result_storyboard_asset(md5sum: str, asset: str): """Serve an explicitly declared, deployment-controlled runner asset.""" normalized = _normalize_task_id(md5sum) @@ -1099,26 +1620,15 @@ def get_result_storyboard_asset(md5sum: str, asset: str): def _result_artifact(task: dict[str, Any], relative_path: str) -> tuple[str, dict[str, Any]] | None: """Resolve only regular files published by the task's finalized manifest.""" - normalized = relative_path.replace("\\", "/").strip("/") - if not normalized or any(part in {"", ".", ".."} for part in normalized.split("/")): - return None - try: - # PTC-W6004: task["result_dir"] is server-owned; the requested path is validated above - with open(_safe_join(task["result_dir"], "manifest.json"), encoding="utf-8") as handle: # skipcq: PTC-W6004 - manifest = json.load(handle) - except (OSError, json.JSONDecodeError): - return None - artifact = next((item for item in manifest.get("artifacts", []) if item.get("path") == normalized), None) - if artifact is None: - return None - path = _safe_join(task["result_dir"], *normalized.split("/")) - if os.path.islink(path) or not os.path.isfile(path): + resolved = current_app.config["storage_resolver"].resolve_artifact(task, relative_path) + if resolved is None: return None - return path, artifact + path = resolved.pop("physical_path") + return path, resolved @app.route("/compute/api/results//artifacts/", methods=["GET"]) -@login_required +@optional_user def get_result_artifact(md5sum: str, relative_path: str): md5sum = _normalize_task_id(md5sum) if md5sum is None: @@ -1132,12 +1642,14 @@ def get_result_artifact(md5sum: str, relative_path: str): if resolved is None: return jsonify({"error": "Artifact not found"}), 404 path, artifact = resolved + if not _task_artifact_access_allowed(task, artifact): + return jsonify({"error": "Artifact not found"}), 404 # Artifacts are untrusted runner output — default to attachment so they # are never rendered same-origin. `?download=1` still forces a download # and `?download=0` explicitly opts back into inline rendering. as_attachment = request.args.get("download", "1") in {"1", "true", "yes"} if app.config["RESULT_DOWNLOAD_MODE"] == "nginx": - internal_path = quote(f"{md5sum}/{artifact['path']}", safe="/") + internal_path = quote(os.path.relpath(path, app.config["RESULTS_FOLDER"]).replace(os.sep, "/"), safe="/") response = Response(status=200, mimetype=artifact.get("media_type") or "application/octet-stream") response.headers["X-Accel-Redirect"] = f"/_protected_results/{internal_path}" response.headers.set( @@ -1148,8 +1660,8 @@ def get_result_artifact(md5sum: str, relative_path: str): response.headers["Cache-Control"] = "private, no-store" return response response = send_from_directory( - task["result_dir"], - artifact["path"], + os.path.dirname(path), + os.path.basename(path), as_attachment=as_attachment, download_name=os.path.basename(path), mimetype=artifact.get("media_type") or None, @@ -1162,7 +1674,7 @@ def get_result_artifact(md5sum: str, relative_path: str): @app.route("/compute/api/results//tables/", methods=["GET"]) -@login_required +@optional_user def get_result_table(md5sum: str, relative_path: str): """Return a bounded, correctly parsed page from a manifest table artifact.""" md5sum = _normalize_task_id(md5sum) @@ -1177,6 +1689,8 @@ def get_result_table(md5sum: str, relative_path: str): if resolved is None: return jsonify({"error": "Table artifact not found"}), 404 path, artifact = resolved + if not _task_artifact_access_allowed(task, artifact): + return jsonify({"error": "Table artifact not found"}), 404 if artifact.get("preview") != "table": return jsonify({"error": "Artifact is not a table"}), 400 try: @@ -1221,7 +1735,7 @@ def request_results_archive(md5sum: str): task = task_store.get_task(md5sum) if task is None: return jsonify({"error": "Task not found"}), 404 - if not _task_access_allowed(task): + if not _task_full_results_allowed(task): return _task_access_denied(md5sum) if task["status"] not in {"finished", "failed"}: return jsonify({"error": "Results are not ready"}), 409 @@ -1240,7 +1754,7 @@ def download_results(md5sum): task = task_store.get_task(md5sum) if not task: return jsonify({"status": "not_found", "md5sum": md5sum}), 404 - if not _task_access_allowed(task): + if not _task_full_results_allowed(task): return _task_access_denied(md5sum) if task["status"] not in {"finished", "failed"}: @@ -1270,7 +1784,7 @@ def download_results(md5sum): ) if app.config["RESULT_DOWNLOAD_MODE"] == "nginx": - archive_name = os.path.basename(zip_filename) + archive_name = os.path.relpath(zip_filename, app.config["RESULTS_FOLDER"]).replace(os.sep, "/") response = Response(status=200, mimetype="application/zip") response.headers["X-Accel-Redirect"] = f"/_protected_results/{archive_name}" response.headers.set("Content-Disposition", "attachment", filename=_task_zip_download_name(task)) @@ -1278,7 +1792,7 @@ def download_results(md5sum): return response return send_from_directory( - app.config["RESULTS_FOLDER"], + os.path.dirname(zip_filename), os.path.basename(zip_filename), as_attachment=True, download_name=_task_zip_download_name(task), @@ -1296,7 +1810,7 @@ def cancel_task(md5sum): task = task_store.get_task(md5sum) if not task: return jsonify({"error": "Task not found"}), 404 - if not _task_access_allowed(task): + if not _task_mutation_allowed(task): return _task_access_denied(md5sum) if task["status"] not in {"pending", "queued", "running"}: @@ -1342,7 +1856,7 @@ def cancel_task(md5sum): _DASHBOARD_SEQUENCE_PREVIEW_BYTES = 4096 -def _dashboard_task_status(task: dict[str, Any], index: int, current_user: str, is_admin: bool) -> dict[str, Any]: +def _dashboard_task_status(task: dict[str, Any], index: int) -> dict[str, Any]: submitted_time = task.get("uploaded_at") finished_time = task.get("finished_at") task_type_name = task.get("task_type", "gremlin") @@ -1395,37 +1909,61 @@ def _dashboard_task_status(task: dict[str, Any], index: int, current_user: str, "structure_format": structure_format, "input_url": f"/compute/api/tasks/{task['md5sum']}/input" if structure_input else None, "owner": task.get("username") or "-", - "can_delete": (is_admin or task.get("username") == current_user) - and task["status"] not in task_store.CLEANUP_CLAIM_STATUSES, + "can_delete": _task_mutation_allowed(task) and task["status"] not in task_store.CLEANUP_CLAIM_STATUSES, "task_type": task.get("task_type", "gremlin"), "running_trace": _build_running_trace(task), "error": _sanitize_task_error(task, task.get("error")), } +def _readonly_task_result_context(task: dict[str, Any]) -> dict[str, Any]: + """Expose only metadata needed to bootstrap the filtered Result Workspace.""" + return { + "md5": task["md5sum"], + "status": task["status"], + "fasta_fn": task["filename"], + "task_type": task.get("task_type", "gremlin"), + } + + @app.route("/compute/dashboard", methods=["GET"]) @login_required def task_dashboard(): # skipcq: PY-R1000 -- dashboard filtering and response assembly share request state. - current_user = _current_username() or "" + current_username = str(g.current_user["username"]) is_admin = _is_admin_user() all_tasks = task_store.list_tasks() - scoped_tasks = all_tasks if is_admin else [task for task in all_tasks if task.get("username") == current_user] + if is_admin: + scoped_tasks = all_tasks + else: + user_id = int(g.current_user["id"]) + store = current_app.config["collaboration"] + scoped_tasks = [ + task + for task in all_tasks + if ( + task.get("scope_type") == "project" + and task.get("scope_id") + and store.get_membership(int(task["scope_id"]), user_id) + ) + or ( + task.get("scope_type") != "project" + and str(task.get("scope_id") or "") == str(user_id) + ) + ] visible_tasks = [task for task in scoped_tasks if not _is_deleted_status(task.get("status"))] - task_statuses = [ - _dashboard_task_status(task, index, current_user, is_admin) for index, task in enumerate(visible_tasks) - ] + task_statuses = [_dashboard_task_status(task, index) for index, task in enumerate(visible_tasks)] sorted_task_statuses = sorted(task_statuses, key=lambda x: x["submitted_timestamp"], reverse=True) return render_template( "dashboard.html", sorted_task_statuses=sorted_task_statuses, - current_username=current_user, + current_username=current_username, is_admin_user=is_admin, ) @app.route("/compute/results/", methods=["GET"]) -@login_required +@optional_user def task_results_page(md5sum): """Render the dedicated manifest-first result workspace for one task.""" normalized = _normalize_task_id(md5sum) @@ -1436,10 +1974,13 @@ def task_results_page(md5sum): abort(404) if not _task_access_allowed(task): return _task_access_denied(normalized) + task_payload = ( + _dashboard_task_status(task, 0) if _task_full_results_allowed(task) else _readonly_task_result_context(task) + ) response = make_response( render_template( "task_results.html", - task=_dashboard_task_status(task, 0, _current_username() or "", _is_admin_user()), + task=task_payload, ) ) response.headers["Cache-Control"] = "no-cache" @@ -1452,7 +1993,7 @@ def task_input_file(md5sum): """Stream a task's uploaded input file (dashboard structure previews). The path comes from the server-owned task row, not from the request; - access is restricted to the task owner (or an admin). + access is restricted to full-result readers. """ normalized = _normalize_task_id(md5sum) if normalized is None: @@ -1460,7 +2001,7 @@ def task_input_file(md5sum): task = task_store.get_task(normalized) if task is None: abort(404) - if not _task_access_allowed(task): + if not _task_full_results_allowed(task): return _task_access_denied(normalized) file_path = str(task.get("file_path") or "") if not file_path or not os.path.isfile(file_path): @@ -1518,7 +2059,7 @@ def delete_task(md5sum): task = task_store.get_task(md5sum) if not task: return jsonify({"status": "not_found", "md5sum": md5sum}), 404 - if not _task_access_allowed(task): + if not _task_mutation_allowed(task): return _task_access_denied(md5sum) if task["status"] in task_store.CLEANUP_CLAIM_STATUSES: return jsonify({"error": "Task cleanup is already in progress", "md5sum": md5sum}), 409 @@ -1560,7 +2101,7 @@ def delete_tasks_batch(): # skipcq: PY-R1000 -- per-task authorization and outc if not task: not_found.append(md5sum) continue - if not _task_access_allowed(task): + if not _task_mutation_allowed(task): forbidden.append(md5sum) continue if task["status"] in task_store.CLEANUP_CLAIM_STATUSES: diff --git a/server/revocompute/schema_epoch.py b/server/revocompute/schema_epoch.py new file mode 100644 index 00000000..dd87e800 --- /dev/null +++ b/server/revocompute/schema_epoch.py @@ -0,0 +1,45 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""Fail-fast validation for the current REvoCompute persistent-state epoch.""" + +from __future__ import annotations + +from collections.abc import Mapping, Set + +import sqlalchemy as sa + +SCHEMA_EPOCH_ERROR = """REvoCompute persistent state predates the Project Scope schema epoch. + +This release intentionally does not migrate old test data. Stop the service, +reset the server databases, workspaces, and results according to the deployment +documentation, then start again.""" + + +def require_current_schema( + connection: sa.Connection, + required_tables: Mapping[str, Set[str]], + *, + database_name: str, +) -> None: + """Reject a non-empty partial or obsolete schema before ``create_all`` mutates it.""" + inspector = sa.inspect(connection) + existing_tables = set(inspector.get_table_names()) + relevant_tables = existing_tables.intersection(required_tables) + if not relevant_tables: + return + + problems: list[str] = [] + for table_name, required_columns in required_tables.items(): + if table_name not in existing_tables: + problems.append(f"missing table {table_name}") + continue + existing_columns = {column["name"] for column in inspector.get_columns(table_name)} + missing_columns = sorted(set(required_columns) - existing_columns) + if missing_columns: + problems.append(f"{table_name} missing columns: {', '.join(missing_columns)}") + + if problems: + detail = "; ".join(problems) + raise RuntimeError(f"{SCHEMA_EPOCH_ERROR}\n\nIncompatible {database_name}: {detail}") diff --git a/server/revocompute/schemas.py b/server/revocompute/schemas.py index e01ba153..cec6cf0f 100644 --- a/server/revocompute/schemas.py +++ b/server/revocompute/schemas.py @@ -206,6 +206,16 @@ class TaskSubmissionRequest(BaseModel): task_type: str = Field(default="gremlin") params: dict[str, Any] = Field(default_factory=dict) + scope_type: Literal["personal", "project"] = "personal" + scope_id: int | None = None + + @model_validator(mode="after") + def _validate_scope(self) -> TaskSubmissionRequest: + if self.scope_type == "project" and self.scope_id is None: + raise ValueError("scope_id is required for Project tasks") + if self.scope_type == "personal" and self.scope_id is not None: + raise ValueError("scope_id must not be supplied for Personal tasks") + return self @field_validator("task_type", mode="before") @classmethod diff --git a/server/revocompute/static/css/create-task.css b/server/revocompute/static/css/create-task.css index 3f40c74d..c336d5ca 100644 --- a/server/revocompute/static/css/create-task.css +++ b/server/revocompute/static/css/create-task.css @@ -51,6 +51,20 @@ .experiment-form-panel, .readiness-panel { border: 1px solid var(--line); border-radius: 18px; background: color-mix(in srgb, var(--paper) 94%, white 6%); box-shadow: 0 12px 28px rgba(29, 42, 47, 0.07); } .experiment-form-panel { overflow: hidden; } +.submission-context { display: grid; grid-template-columns: minmax(0, 1fr) minmax(250px, 0.55fr); gap: 1rem; padding: 1.15rem 1.25rem; border-bottom: 1px solid var(--line); } +.submission-context header { grid-column: 1 / -1; } +.submission-context h2 { margin: 0.3rem 0 0; font-family: ui-serif, Georgia, serif; font-size: 1.2rem; } +.scope-options { display: flex; flex-wrap: wrap; gap: 0.5rem; align-content: start; } +.scope-option { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 0.45rem; align-items: start; min-width: 9rem; max-width: 100%; padding: 0.6rem 0.7rem; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; } +.scope-option:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--paper) 90%, var(--accent) 10%); } +.scope-option input { margin-top: 0.15rem; accent-color: var(--accent); } +.scope-option strong, .scope-option small { display: block; overflow-wrap: anywhere; } +.scope-option strong { font-size: 0.76rem; } +.scope-option small { margin-top: 0.15rem; color: var(--muted); font-size: 0.65rem; } +.artifact-reference-field { display: grid; gap: 0.35rem; } +.artifact-reference-field > span { font-size: 0.74rem; font-weight: 700; } +.artifact-reference-field > span small { color: var(--muted); font-weight: 500; } +.artifact-reference-field textarea { min-height: 5.2rem; resize: vertical; font: 0.7rem/1.45 ui-monospace, monospace; } .input-workspace { display: grid; } .protocol-step { scroll-margin-top: 1rem; padding: 1.25rem; } .protocol-step + .protocol-step { border-top: 1px solid var(--line); } @@ -145,6 +159,8 @@ html[data-theme="dark"] .method-card, html[data-theme="dark"] .experiment-form-p .experiment-nav { align-items: flex-start; } .experiment-brand span { display: none; } .method-grid, .method-contract, .experiment-layout { grid-template-columns: 1fr; } + .submission-context { grid-template-columns: 1fr; } + .submission-context header { grid-column: 1; } .method-contract div + div { padding-left: 0; border-left: 0; border-top: 1px solid var(--line); } .method-brief { grid-template-columns: 1fr; } .method-brief > .btn { justify-self: start; } diff --git a/server/revocompute/static/css/projects.css b/server/revocompute/static/css/projects.css new file mode 100644 index 00000000..0667711c --- /dev/null +++ b/server/revocompute/static/css/projects.css @@ -0,0 +1,103 @@ +/* REvoCompute - project collaboration surfaces */ +/* SPDX-License-Identifier: GPL-3.0-only */ + +[hidden] { display: none !important; } +.projects-page { width: min(1180px, calc(100% - 2rem)); } +.projects-nav { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 0.35rem 0 1.2rem; border-bottom: 1px solid var(--line); } +.projects-brand { display: inline-flex; align-items: center; gap: 0.65rem; color: var(--ink); font: 700 1rem "Source Serif 4", serif; text-decoration: none; } +.projects-brand img { border-radius: 50%; } +.projects-eyebrow { margin: 0; color: var(--accent-2); font-size: 0.68rem; font-weight: 750; letter-spacing: 0.12em; text-transform: uppercase; } +.projects-heading, .project-identity { display: flex; align-items: flex-end; justify-content: space-between; gap: 2rem; padding: 2.8rem 0 2rem; } +.projects-heading h1, .project-identity h1 { margin: 0.35rem 0 0; font: 600 clamp(2.2rem, 5vw, 4rem)/1 "Source Serif 4", serif; letter-spacing: 0; overflow-wrap: anywhere; } +.projects-lede { max-width: 720px; margin: 0.75rem 0 0; color: var(--muted); font-size: 0.95rem; line-height: 1.6; } +.project-identity-actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 0.65rem; } +.visibility-badge, .project-role-badge, .task-status-badge { display: inline-flex; align-items: center; min-height: 1.7rem; padding: 0.2rem 0.55rem; border: 1px solid var(--line); border-radius: 999px; color: var(--muted); font-size: 0.68rem; font-weight: 700; text-transform: capitalize; white-space: nowrap; } + +.project-section, .create-project-panel, .project-tab-panel { padding: 1.4rem 0; border-top: 1px solid var(--line); } +.project-section + .project-section { margin-top: 1.4rem; } +.project-section.compact { margin-top: 1.4rem; } +.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; } +.section-heading h2, .section-heading h3 { margin: 0.25rem 0 0; font: 600 1.35rem/1.2 "Source Serif 4", serif; } +.section-heading h3 { font-size: 1rem; } +.project-search { width: min(320px, 45vw); } +.icon-button { display: inline-grid; place-items: center; width: 2.25rem; height: 2.25rem; border: 1px solid var(--line); border-radius: 50%; background: transparent; color: var(--muted); font-size: 1.35rem; cursor: pointer; } +.text-button { padding: 0; border: 0; background: transparent; color: var(--accent); font: 700 0.78rem inherit; cursor: pointer; } + +.project-form { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, 0.3fr); gap: 1rem; max-width: 820px; } +.field { display: grid; gap: 0.35rem; min-width: 0; } +.field > span { font-size: 0.75rem; font-weight: 700; } +.field-wide { grid-column: 1 / -1; } +.project-textarea { min-height: 6rem; resize: vertical; } +.form-actions { display: flex; align-items: center; gap: 0.8rem; } +.form-status { margin: 0; color: var(--muted); font-size: 0.76rem; } +.form-status.error, .project-status.error { color: var(--failed); } +.form-status.ok, .project-status.ok { color: var(--finished); } + +.project-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 290px), 1fr)); gap: 0.75rem; } +.project-card { display: grid; grid-template-rows: auto 1fr auto; min-height: 190px; padding: 1rem; border: 1px solid var(--line); border-radius: 8px; background: color-mix(in srgb, var(--paper) 95%, white 5%); color: var(--ink); text-decoration: none; } +.project-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--line)); box-shadow: 0 10px 22px rgba(29, 42, 47, 0.07); } +.project-card-head, .project-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 0.65rem; } +.project-card h3 { margin: 0.75rem 0 0.4rem; font: 600 1.1rem "Source Serif 4", serif; overflow-wrap: anywhere; } +.project-card p { margin: 0; color: var(--muted); font-size: 0.78rem; line-height: 1.5; } +.project-card-meta { align-self: end; margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--line); color: var(--muted); font-size: 0.68rem; } +.empty-state { margin: 0; padding: 2rem 0; color: var(--muted); text-align: center; } + +.invitation-list, .member-list, .project-task-list { display: grid; } +.invitation-row, .member-row, .project-task-row { display: grid; align-items: center; gap: 1rem; padding: 0.85rem 0; border-bottom: 1px solid var(--line); } +.invitation-row { grid-template-columns: minmax(0, 1fr) auto; } +.member-row { grid-template-columns: minmax(0, 1fr) minmax(150px, auto) auto; } +.project-task-row { grid-template-columns: minmax(0, 1fr) auto auto; color: var(--ink); text-decoration: none; } +.invitation-row strong, .member-row strong, .project-task-row strong { display: block; overflow-wrap: anywhere; font-size: 0.86rem; } +.invitation-row small, .member-row small, .project-task-row small { display: block; margin-top: 0.2rem; color: var(--muted); font-size: 0.7rem; } +.row-actions { display: inline-flex; flex-wrap: wrap; justify-content: flex-end; gap: 0.4rem; } +.row-button { min-height: 2rem; padding: 0.32rem 0.65rem; border: 1px solid var(--line); border-radius: 5px; background: var(--paper); color: var(--ink); font: 650 0.72rem inherit; cursor: pointer; } +.row-button.primary { border-color: var(--accent); background: var(--accent); color: white; } +.row-button.danger { color: var(--failed); } +.member-role-select { min-width: 9rem; padding: 0.42rem; border: 1px solid var(--line); border-radius: 5px; background: var(--paper); color: var(--ink); } + +.project-tabs { display: flex; gap: 1.25rem; overflow-x: auto; border-bottom: 1px solid var(--line); } +.project-tab { position: relative; flex: 0 0 auto; padding: 0.8rem 0.1rem; border: 0; background: transparent; color: var(--muted); font: 700 0.78rem inherit; cursor: pointer; } +.project-tab.active { color: var(--ink); } +.project-tab.active::after { content: ""; position: absolute; right: 0; bottom: -1px; left: 0; height: 2px; background: var(--accent); } +.project-status { min-height: 1.3rem; padding-top: 0.7rem; color: var(--muted); font-size: 0.75rem; } +.project-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); } +.project-metrics > div { padding: 1.1rem; } +.project-metrics > div + div { border-left: 1px solid var(--line); } +.project-metrics span { display: block; color: var(--muted); font-size: 0.68rem; text-transform: uppercase; } +.project-metrics strong { display: block; margin-top: 0.25rem; font: 600 1.25rem "Source Serif 4", serif; overflow-wrap: anywhere; } + +.invite-form { display: grid; grid-template-columns: minmax(220px, 1fr) 180px auto; gap: 0.75rem; align-items: end; margin-bottom: 1rem; } +.pending-invitations { margin-top: 2rem; } +.settings-form { display: grid; gap: 2rem; max-width: 820px; } +.settings-section { display: grid; gap: 1rem; } +.settings-section header h2, .danger-zone h2 { margin: 0.3rem 0 0; font: 600 1.25rem "Source Serif 4", serif; } +.visibility-options { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.55rem; } +.visibility-options label { display: grid; grid-template-columns: auto 1fr; gap: 0.5rem; align-items: start; padding: 0.75rem; border: 1px solid var(--line); border-radius: 7px; cursor: pointer; } +.visibility-options label:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--paper) 90%, var(--accent) 10%); } +.visibility-options input { margin-top: 0.15rem; accent-color: var(--accent); } +.visibility-options strong, .visibility-options small { display: block; } +.visibility-options small { margin-top: 0.2rem; color: var(--muted); font-size: 0.68rem; line-height: 1.4; } +.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin-top: 2rem; padding: 1rem; border: 1px solid color-mix(in srgb, var(--failed) 45%, var(--line)); border-radius: 8px; } +.danger-zone p:last-child { margin: 0.35rem 0 0; color: var(--muted); font-size: 0.75rem; } +.danger-button { flex: 0 0 auto; border-color: var(--failed); background: transparent; color: var(--failed); } + +html[data-theme="dark"] .project-card { background: color-mix(in srgb, var(--paper) 95%, #0f171c 5%); } +html[data-theme="dark"] .row-button, html[data-theme="dark"] .member-role-select { background: #18262d; } + +@media (max-width: 720px) { + .projects-page { width: min(100% - 1rem, 620px); margin-top: 0.6rem; } + .projects-nav, .projects-heading, .project-identity, .danger-zone { align-items: flex-start; } + .projects-brand span { display: none; } + .projects-heading, .project-identity, .danger-zone { flex-direction: column; } + .project-identity-actions { justify-content: flex-start; } + .project-form, .invite-form, .visibility-options { grid-template-columns: 1fr; } + .field-wide { grid-column: 1; } + .member-row, .project-task-row { grid-template-columns: 1fr; gap: 0.5rem; } + .row-actions { justify-content: flex-start; } + .project-metrics > div { padding: 0.75rem; } + .project-search { width: min(55vw, 250px); } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0s !important; animation: none !important; } +} diff --git a/server/revocompute/static/js/create-task.js b/server/revocompute/static/js/create-task.js index eab0324e..0f106bdd 100644 --- a/server/revocompute/static/js/create-task.js +++ b/server/revocompute/static/js/create-task.js @@ -12,7 +12,8 @@ var methodGroups = document.getElementById("methodGroups"), methodSearch = document.getElementById("methodSearch"); var catalogStatus = document.getElementById("catalogStatus"), protocolTrack = document.getElementById("protocolTrack"); var validationChecks = document.getElementById("validationChecks"), validationSummary = document.getElementById("validationSummary"); - var catalog = { categories: [], task_types: [] }, currentForm = null, loadController = null, loadGeneration = 0; + var scopeOptions = document.getElementById("scopeOptions"), artifactReferencesInput = document.getElementById("artifactReferences"); + var catalog = { categories: [], task_types: [] }, currentForm = null, loadController = null, loadGeneration = 0, scopeReady = false, unresolvedRequestedScope = false; function setStatus(message, kind) { statusNode.className = "status" + (kind ? " " + kind : ""); statusNode.textContent = message; @@ -26,6 +27,53 @@ } function categoryFor(name) { return catalog.categories.find(function (category) { return category.name === name; }); } + function artifactReferences() { + var seen = {}; + return artifactReferencesInput.value.split(/\r?\n/).map(function (value) { return value.trim(); }).filter(function (value) { + if (!value || seen[value]) return false; seen[value] = true; return true; + }); + } + + function artifactReferenceErrors(references) { + return references.filter(function (reference) { + var match = /^@([0-9a-fA-F]{32})\/(.+)$/.exec(reference); + if (!match || match[2].includes("\\") || match[2].startsWith("/") || match[2].includes("\u0000")) return true; + return match[2].split("/").some(function (segment) { return !segment || segment === "." || segment === ".."; }); + }).map(function (reference) { return "Invalid artifact reference: " + reference; }); + } + + function addProjectScope(project) { + var label = document.createElement("label"); label.className = "scope-option"; + var input = document.createElement("input"); input.type = "radio"; input.name = "taskScope"; input.value = "project"; input.dataset.scopeId = String(project.id || project.project_id); + var copy = document.createElement("span"), title = document.createElement("strong"), detail = document.createElement("small"); + title.textContent = project.name; detail.textContent = "Project scope"; copy.append(title, detail); label.append(input, copy); scopeOptions.appendChild(label); + } + + async function loadWritableProjects() { + try { + var response = await A.authFetch("/compute/api/projects?capability=submit_tasks"); + if (!response.ok) throw new Error("Failed to load project scopes"); + var payload = await response.json(), projects = Array.isArray(payload) ? payload : (payload.projects || []); + projects.forEach(addProjectScope); + var query = new URLSearchParams(window.location.search), requestedType = query.get("scope_type"), requestedId = query.get("scope_id"); + if (requestedType === "project" && requestedId) { + var requested = Array.from(scopeOptions.querySelectorAll('input[value="project"]')).find(function (input) { return input.dataset.scopeId === requestedId; }); + if (requested) requested.checked = true; + else { + unresolvedRequestedScope = true; + scopeOptions.querySelectorAll('input[name="taskScope"]').forEach(function (input) { input.checked = false; }); + setStatus("The requested Project is unavailable. Select another scope.", "error"); + } + } + } catch (error) { + var requestedProject = new URLSearchParams(window.location.search).get("scope_type") === "project"; + unresolvedRequestedScope = requestedProject; + setStatus(requestedProject ? "The requested Project could not be loaded. Select another scope." : "Project scopes are temporarily unavailable. Personal scope remains available.", "error"); + } finally { + scopeReady = true; if (currentForm) refreshValidation(); + } + } + function selectMethod(name) { var exists = catalog.task_types.some(function (task) { return task.name === name; }); if (!exists) return showChooser(name ? "That method is not available on this server." : "Choose a method to begin."); @@ -117,7 +165,18 @@ function refreshValidation() { validationChecks.replaceChildren(); if (!currentForm) { validationSummary.textContent = "Choose a method"; submitButton.disabled = true; return []; } - var errors = workspace.validate(), files = workspace.files(), sequence = workspace.sequence(); + var references = artifactReferences(), errors = workspace.validate(), files = workspace.files(), sequence = workspace.sequence(); + var referenceErrors = artifactReferenceErrors(references); + artifactReferencesInput.setAttribute("aria-invalid", referenceErrors.length ? "true" : "false"); + if (!scopeReady) errors.push("Loading task scopes."); + if (unresolvedRequestedScope) errors.push("Select a scope for this task."); + if (references.length && !referenceErrors.length && !files.length && !sequence) { + errors = errors.filter(function (error) { return error !== "Choose an input file or provide a sequence."; }); + workspaceRoot.querySelectorAll('[id^="file_error_"]').forEach(function (error) { + if (error.textContent === "Choose an input file or provide a sequence.") { error.hidden = true; var control = workspaceRoot.querySelector('[aria-describedby="' + error.id + '"]'); if (control) control.removeAttribute("aria-invalid"); } + }); + } + errors = errors.concat(referenceErrors); if (errors.length) errors.forEach(function (error) { validationChecks.appendChild(validationRow("error", error)); }); else { validationChecks.appendChild(validationRow("ok", "Input contract satisfied")); @@ -148,6 +207,11 @@ } var formData = new FormData(); files.forEach(function (file) { formData.append("files", file); formData.append("input_paths", file.webkitRelativePath || file.name); }); + artifactReferences().forEach(function (reference) { formData.append("artifact_references", reference); }); + var selectedScope = scopeOptions.querySelector('input[name="taskScope"]:checked'); + if (!selectedScope || unresolvedRequestedScope) { setStatus("Select a scope before submitting.", "error"); return; } + formData.append("scope_type", selectedScope.value); + if (selectedScope && selectedScope.value === "project") formData.append("scope_id", selectedScope.dataset.scopeId); formData.append("task_type", currentForm.name); formData.append("workspace", JSON.stringify({ version: 2, capabilities: capabilities })); var params = workspace.paramValues(); Object.keys(params).forEach(function (name) { formData.append("params[" + name + "]", params[name]); }); @@ -163,9 +227,11 @@ } form.addEventListener("submit", function (event) { event.preventDefault(); submitTask(); }); - clearButton.addEventListener("click", function () { if (!currentForm) return; workspace.mount(currentForm); setStatus("Workspace cleared.", "ok"); refreshValidation(); var first = form.querySelector("button, input, textarea, select"); if (first) first.focus(); }); + clearButton.addEventListener("click", function () { if (!currentForm) return; workspace.mount(currentForm); artifactReferencesInput.value = ""; setStatus("Workspace cleared.", "ok"); refreshValidation(); var first = form.querySelector("button, input, textarea, select"); if (first) first.focus(); }); document.getElementById("changeMethod").addEventListener("click", function () { showChooser("Choose another method."); }); methodSearch.addEventListener("input", function () { renderCatalog(methodSearch.value); }); + artifactReferencesInput.addEventListener("input", refreshValidation); + scopeOptions.addEventListener("change", function () { unresolvedRequestedScope = false; refreshValidation(); }); var dropZone = document.querySelector(".experiment-form-panel"); function dragOver(event) { event.preventDefault(); event.dataTransfer.dropEffect = "copy"; dropZone.classList.add("drop-highlight"); } @@ -187,5 +253,5 @@ } catch (error) { catalogStatus.textContent = "Could not reach the server. Check your connection and reload the page."; catalogStatus.className = "status error"; } } - T.initToggle(document.getElementById("themeToggle")); loadCatalog(); + T.initToggle(document.getElementById("themeToggle")); loadWritableProjects(); loadCatalog(); })(); diff --git a/server/revocompute/static/js/project.js b/server/revocompute/static/js/project.js new file mode 100644 index 00000000..8516880f --- /dev/null +++ b/server/revocompute/static/js/project.js @@ -0,0 +1,210 @@ +/* REvoCompute - project overview, tasks, members, and settings */ +/* SPDX-License-Identifier: GPL-3.0-only */ + +(function () { + "use strict"; + var A = window.REvoDesignAuth; + var T = window.REvoDesignTheme; + var pageData = JSON.parse(document.getElementById("project-page-data").textContent); + var projectId = pageData.project_id; + var apiRoot = "/compute/api/projects/" + encodeURIComponent(projectId); + var state = { project: null, capabilities: [], role: null, tasks: null, members: null, invitations: null, users: [] }; + var statusNode = document.getElementById("projectStatus"); + + function has(capability) { return state.capabilities.indexOf(capability) !== -1; } + function setStatus(message, kind) { statusNode.textContent = message || ""; statusNode.className = "project-status" + (kind ? " " + kind : ""); } + + async function request(url, options, publicRead) { + var response = publicRead ? await fetch(url, { credentials: "same-origin", headers: { "Accept": "application/json" } }) : await A.authFetch(url, options); + var payload = (response.headers.get("Content-Type") || "").includes("application/json") ? await response.json() : {}; + if (!response.ok) throw new Error(payload.error || payload.message || "Request failed (HTTP " + response.status + ")"); + return payload; + } + + function formatDate(value) { + if (!value) return ""; + var numeric = Number(value), date = Number.isFinite(numeric) ? new Date(numeric < 100000000000 ? numeric * 1000 : numeric) : new Date(value); + return Number.isNaN(date.getTime()) ? "" : date.toLocaleDateString(); + } + + function normalizeTask(task) { + return { id: task.md5 || task.md5sum || task.task_id || task.id, name: task.fasta_fn || task.filename || task.input_name || task.name || task.task_type || "Compute task", type: task.task_type || "task", status: task.status || "pending", date: task.submitted_time || task.uploaded_at || task.created_at }; + } + + function taskRow(task) { + var normalized = normalizeTask(task), row = document.createElement("a"); + row.className = "project-task-row"; row.href = "/compute/results/" + encodeURIComponent(normalized.id); + var copy = document.createElement("div"), title = document.createElement("strong"), detail = document.createElement("small"); + title.textContent = normalized.name; detail.textContent = normalized.type + (normalized.date ? " | " + formatDate(normalized.date) : ""); copy.append(title, detail); + var identifier = document.createElement("small"); identifier.textContent = normalized.id; + var badge = document.createElement("span"); badge.className = "task-status-badge"; badge.textContent = normalized.status; + row.append(copy, identifier, badge); return row; + } + + function renderTaskList(target, tasks, limit) { + target.replaceChildren(); (limit ? tasks.slice(0, limit) : tasks).forEach(function (task) { target.appendChild(taskRow(task)); }); + } + + async function loadTasks() { + if (state.tasks) return state.tasks; + var payload = await request(apiRoot + "/tasks", undefined, true); + state.tasks = Array.isArray(payload) ? payload : (payload.tasks || []); + renderTaskList(document.getElementById("projectTaskList"), state.tasks); + renderTaskList(document.getElementById("recentTaskList"), state.tasks, 5); + document.getElementById("projectTasksEmpty").hidden = state.tasks.length > 0; + document.getElementById("projectTaskCount").textContent = String(state.tasks.length); + return state.tasks; + } + + function memberRow(member) { + var row = document.createElement("article"); row.className = "member-row"; + var copy = document.createElement("div"), title = document.createElement("strong"), detail = document.createElement("small"); + title.textContent = member.username || member.display_name || ("User " + member.user_id); detail.textContent = member.role; copy.append(title, detail); row.appendChild(copy); + if (has("manage_members") && member.role !== "owner") { + var select = document.createElement("select"); select.className = "member-role-select"; select.setAttribute("aria-label", "Role for " + title.textContent); + ["viewer", "contributor", "maintainer"].forEach(function (role) { var option = document.createElement("option"); option.value = role; option.textContent = role.charAt(0).toUpperCase() + role.slice(1); option.selected = member.role === role; select.appendChild(option); }); + select.addEventListener("change", function () { updateMemberRole(member, select); }); row.appendChild(select); + var actions = document.createElement("div"); actions.className = "row-actions"; + if (has("transfer_ownership")) { + var transfer = document.createElement("button"); transfer.type = "button"; transfer.className = "row-button"; transfer.textContent = "Make owner"; + transfer.addEventListener("click", function () { transferOwnership(member, row); }); actions.appendChild(transfer); + } + var remove = document.createElement("button"); remove.type = "button"; remove.className = "row-button danger"; remove.textContent = "Remove"; + remove.addEventListener("click", function () { removeMember(member, row); }); actions.appendChild(remove); row.appendChild(actions); + } else { + var badge = document.createElement("span"); badge.className = "project-role-badge"; badge.textContent = member.role; row.appendChild(badge); row.appendChild(document.createElement("span")); + } + return row; + } + + async function loadMembers(force) { + if (state.members && !force) return state.members; + var payload = await request(apiRoot + "/members"); state.members = Array.isArray(payload) ? payload : (payload.members || []); + var list = document.getElementById("memberList"); list.replaceChildren(); state.members.forEach(function (member) { list.appendChild(memberRow(member)); }); + document.getElementById("projectMemberCount").textContent = String(state.members.length); return state.members; + } + + async function updateMemberRole(member, select) { + select.disabled = true; + try { + await request(apiRoot + "/members/" + encodeURIComponent(member.user_id), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ role: select.value }) }); + state.members = null; await loadMembers(true); setStatus("Member role updated.", "ok"); + } catch (error) { select.value = member.role; select.disabled = false; setStatus(error.message, "error"); } + } + + async function removeMember(member, row) { + if (!window.confirm("Remove " + (member.username || "this member") + " from the project?")) return; + row.querySelectorAll("button, select").forEach(function (control) { control.disabled = true; }); + try { await request(apiRoot + "/members/" + encodeURIComponent(member.user_id), { method: "DELETE" }); state.members = null; await loadMembers(true); setStatus("Member removed.", "ok"); } + catch (error) { row.querySelectorAll("button, select").forEach(function (control) { control.disabled = false; }); setStatus(error.message, "error"); } + } + + async function transferOwnership(member, row) { + if (!window.confirm("Transfer project ownership to " + (member.username || "this member") + "?")) return; + row.querySelectorAll("button, select").forEach(function (control) { control.disabled = true; }); + try { + await request(apiRoot + "/transfer-ownership", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: member.user_id }) }); + state.members = null; await loadProject(); await loadMembers(true); setStatus("Project ownership transferred.", "ok"); + } catch (error) { row.querySelectorAll("button, select").forEach(function (control) { control.disabled = false; }); setStatus(error.message, "error"); } + } + + function invitationRow(invitation) { + var row = document.createElement("article"); row.className = "invitation-row"; + var copy = document.createElement("div"), title = document.createElement("strong"), detail = document.createElement("small"); + title.textContent = invitation.invited_username || invitation.username || ("User " + invitation.invited_user_id); detail.textContent = (invitation.proposed_role || invitation.role) + (invitation.expires_at ? " | expires " + formatDate(invitation.expires_at) : ""); copy.append(title, detail); row.appendChild(copy); + if (has("invite_members") || has("manage_members")) { + var actions = document.createElement("div"); actions.className = "row-actions"; var revoke = document.createElement("button"); revoke.type = "button"; revoke.className = "row-button danger"; revoke.textContent = "Revoke"; + revoke.addEventListener("click", function () { revokeInvitation(invitation, row); }); actions.appendChild(revoke); row.appendChild(actions); + } + return row; + } + + async function loadInvitations(force) { + if (state.invitations && !force) return state.invitations; + var payload = await request(apiRoot + "/invitations"); state.invitations = Array.isArray(payload) ? payload : (payload.invitations || []); + var section = document.getElementById("pendingInvitations"), list = document.getElementById("projectInvitationList"); list.replaceChildren(); + state.invitations.forEach(function (invitation) { list.appendChild(invitationRow(invitation)); }); section.hidden = state.invitations.length === 0; return state.invitations; + } + + async function revokeInvitation(invitation, row) { + row.querySelectorAll("button").forEach(function (button) { button.disabled = true; }); + try { await request(apiRoot + "/invitations/" + encodeURIComponent(invitation.id), { method: "DELETE" }); state.invitations = null; await loadInvitations(true); setStatus("Invitation revoked.", "ok"); } + catch (error) { row.querySelectorAll("button").forEach(function (button) { button.disabled = false; }); setStatus(error.message, "error"); } + } + + var searchTimer = null; + document.getElementById("inviteUserSearch").addEventListener("input", function (event) { + window.clearTimeout(searchTimer); var query = event.target.value.trim(); if (query.length < 2) return; + searchTimer = window.setTimeout(async function () { + try { + var payload = await request(apiRoot + "/users/search?q=" + encodeURIComponent(query)); state.users = payload.users || []; + var options = document.getElementById("inviteUserOptions"); options.replaceChildren(); state.users.forEach(function (user) { var option = document.createElement("option"); option.value = user.username; option.label = user.display_name || user.username; options.appendChild(option); }); + } catch (error) { setStatus(error.message, "error"); } + }, 200); + }); + + document.getElementById("inviteMemberForm").addEventListener("submit", async function (event) { + event.preventDefault(); var input = document.getElementById("inviteUserSearch"), username = input.value.trim().toLowerCase(); + var user = state.users.find(function (candidate) { return String(candidate.username).toLowerCase() === username; }); + if (!user) return setStatus("Select an existing user from the search results.", "error"); + var submit = event.target.querySelector('[type="submit"]'); submit.disabled = true; + try { + await request(apiRoot + "/invitations", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ user_id: user.id, role: document.getElementById("inviteRole").value }) }); + input.value = ""; state.invitations = null; await loadInvitations(true); setStatus("Invitation sent.", "ok"); + } catch (error) { setStatus(error.message, "error"); } + finally { submit.disabled = false; } + }); + + function openTab(name) { + document.querySelectorAll(".project-tab").forEach(function (tab) { + var selected = tab.dataset.tab === name; tab.classList.toggle("active", selected); tab.setAttribute("aria-selected", String(selected)); + }); + document.querySelectorAll(".project-tab-panel").forEach(function (panel) { panel.hidden = panel.dataset.panel !== name; }); + if (name === "tasks") loadTasks().catch(function (error) { setStatus(error.message, "error"); }); + if (name === "members") Promise.all([loadMembers(), has("manage_members") ? loadInvitations() : Promise.resolve([])]).catch(function (error) { setStatus(error.message, "error"); }); + } + + document.querySelectorAll(".project-tab").forEach(function (tab) { tab.addEventListener("click", function () { openTab(tab.dataset.tab); }); }); + document.querySelectorAll("[data-open-tab]").forEach(function (button) { button.addEventListener("click", function () { openTab(button.dataset.openTab); }); }); + + document.getElementById("projectSettingsForm").addEventListener("submit", async function (event) { + event.preventDefault(); var save = document.getElementById("saveProjectSettings"); save.disabled = true; + try { + var checked = document.querySelector('input[name="settingsVisibility"]:checked'); + await request(apiRoot, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: document.getElementById("settingsName").value.trim(), description: document.getElementById("settingsDescription").value.trim(), visibility: checked ? checked.value : state.project.visibility }) }); + await loadProject(); setStatus("Project settings saved.", "ok"); + } catch (error) { setStatus(error.message, "error"); } + finally { save.disabled = false; } + }); + + document.getElementById("archiveProject").addEventListener("click", async function () { + if (!window.confirm("Archive this project? New tasks and membership changes will stop.")) return; + var button = document.getElementById("archiveProject"); button.disabled = true; + try { await request(apiRoot, { method: "DELETE" }); window.location.assign("/compute/projects"); } + catch (error) { button.disabled = false; setStatus(error.message, "error"); } + }); + + async function loadProject() { + var payload = await request(apiRoot, undefined, true), project = payload.project || payload; + state.project = project; state.capabilities = payload.capabilities || project.capabilities || []; state.role = payload.membership_role || project.membership_role || (payload.membership && payload.membership.role) || null; + document.getElementById("projectTitle").textContent = project.name; + document.getElementById("projectDescription").textContent = project.description || "No description"; + document.getElementById("projectVisibilityBadge").textContent = project.visibility; + document.getElementById("projectRole").textContent = state.role || "Reader"; + document.getElementById("projectTaskCount").textContent = String(project.task_count || 0); + document.getElementById("projectMemberCount").textContent = String(project.member_count || 0); + document.getElementById("settingsName").value = project.name || ""; document.getElementById("settingsDescription").value = project.description || ""; + var visibility = document.querySelector('input[name="settingsVisibility"][value="' + project.visibility + '"]'); if (visibility) visibility.checked = true; + var taskButton = document.getElementById("newProjectTask"); taskButton.hidden = !has("submit_tasks"); taskButton.href = "/compute/create_task?scope_type=project&scope_id=" + encodeURIComponent(projectId); + var canSettings = has("change_project_settings"); document.getElementById("projectSettingsForm").querySelectorAll("input, textarea, button").forEach(function (control) { control.disabled = !canSettings; }); + document.getElementById("dangerZone").hidden = !has("delete_project"); + document.getElementById("inviteMemberForm").hidden = !has("invite_members"); + document.querySelector('[data-tab="members"]').hidden = !state.role; + document.querySelector('[data-tab="settings"]').hidden = !canSettings && !has("delete_project"); + if (project.archived_at) { document.getElementById("projectScopeLabel").textContent = "Archived"; document.getElementById("projectVisibilityBadge").textContent = project.visibility + " | archived"; taskButton.hidden = true; } + return project; + } + + T.initToggle(document.getElementById("themeToggle")); + loadProject().then(function () { return Promise.all([loadTasks(), state.role ? loadMembers() : Promise.resolve([])]); }).catch(function (error) { setStatus(error.message, "error"); document.getElementById("projectTitle").textContent = "Project unavailable"; }); +})(); diff --git a/server/revocompute/static/js/projects.js b/server/revocompute/static/js/projects.js new file mode 100644 index 00000000..42f7c164 --- /dev/null +++ b/server/revocompute/static/js/projects.js @@ -0,0 +1,129 @@ +/* REvoCompute - project list and invitation inbox */ +/* SPDX-License-Identifier: GPL-3.0-only */ + +(function () { + "use strict"; + var A = window.REvoDesignAuth; + var T = window.REvoDesignTheme; + var projects = []; + var grid = document.getElementById("projectGrid"); + var projectSearch = document.getElementById("projectSearch"); + var createPanel = document.getElementById("createProjectPanel"); + var createForm = document.getElementById("createProjectForm"); + var createStatus = document.getElementById("createProjectStatus"); + + function setStatus(node, message, kind) { + node.textContent = message || ""; + node.className = "form-status" + (kind ? " " + kind : ""); + } + + async function request(url, options) { + var response = await A.authFetch(url, options); + var payload = (response.headers.get("Content-Type") || "").includes("application/json") ? await response.json() : {}; + if (!response.ok) throw new Error(payload.error || payload.message || "Request failed (HTTP " + response.status + ")"); + return payload; + } + + function projectId(project) { return project.id || project.project_id; } + + function projectCard(project) { + var card = document.createElement("a"); + card.className = "project-card"; + card.href = "/compute/projects/" + encodeURIComponent(projectId(project)); + var head = document.createElement("div"); head.className = "project-card-head"; + var visibility = document.createElement("span"); visibility.className = "visibility-badge"; visibility.textContent = (project.visibility || "private") + (project.archived_at ? " | archived" : ""); + var role = document.createElement("span"); role.className = "project-role-badge"; role.textContent = project.membership_role || project.role || "Read only"; + head.append(visibility, role); + var body = document.createElement("div"); + var title = document.createElement("h3"); title.textContent = project.name || "Untitled project"; + var description = document.createElement("p"); description.textContent = project.description || "No description"; + body.append(title, description); + var meta = document.createElement("div"); meta.className = "project-card-meta"; + var tasks = document.createElement("span"); tasks.textContent = String(project.task_count || 0) + " tasks"; + var members = document.createElement("span"); members.textContent = String(project.member_count || 0) + " members"; + meta.append(tasks, members); card.append(head, body, meta); return card; + } + + function renderProjects() { + var query = projectSearch.value.trim().toLowerCase(); + var visible = projects.filter(function (project) { + return !query || [project.name, project.description, project.visibility, project.archived_at ? "archived" : "active", project.membership_role, project.role].join(" ").toLowerCase().includes(query); + }); + grid.replaceChildren(); visible.forEach(function (project) { grid.appendChild(projectCard(project)); }); + document.getElementById("projectsEmpty").hidden = visible.length > 0; + } + + async function loadProjects() { + try { + var payload = await request("/compute/api/projects"); + projects = Array.isArray(payload) ? payload : (payload.projects || []); + renderProjects(); + } catch (error) { + grid.replaceChildren(); document.getElementById("projectsEmpty").hidden = false; + document.getElementById("projectsEmpty").textContent = error.message; + } + } + + function invitationRow(invitation) { + var row = document.createElement("article"); row.className = "invitation-row"; + var copy = document.createElement("div"); + var title = document.createElement("strong"); title.textContent = invitation.project_name || "Project invitation"; + var detail = document.createElement("small"); detail.textContent = (invitation.proposed_role || invitation.role || "viewer") + " role"; + copy.append(title, detail); + var actions = document.createElement("div"); actions.className = "row-actions"; + var accept = document.createElement("button"); accept.type = "button"; accept.className = "row-button primary"; accept.textContent = "Accept"; + var decline = document.createElement("button"); decline.type = "button"; decline.className = "row-button"; decline.textContent = "Decline"; + accept.addEventListener("click", function () { respondInvitation(invitation.id, true, row); }); + decline.addEventListener("click", function () { respondInvitation(invitation.id, false, row); }); + actions.append(accept, decline); row.append(copy, actions); return row; + } + + async function respondInvitation(invitationId, accept, row) { + row.querySelectorAll("button").forEach(function (button) { button.disabled = true; }); + try { + await request("/compute/api/invitations/" + encodeURIComponent(invitationId), { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: accept ? "accept" : "decline" }) + }); + await Promise.all([loadProjects(), loadInvitations()]); + } catch (error) { + row.querySelectorAll("button").forEach(function (button) { button.disabled = false; }); + window.alert(error.message); + } + } + + async function loadInvitations() { + var list = document.getElementById("invitationList"), empty = document.getElementById("invitationsEmpty"); + try { + var payload = await request("/compute/api/invitations?status=pending"); + var invitations = Array.isArray(payload) ? payload : (payload.invitations || []); + list.replaceChildren(); invitations.forEach(function (invitation) { list.appendChild(invitationRow(invitation)); }); + empty.hidden = invitations.length > 0; + } catch (error) { + list.replaceChildren(); empty.hidden = false; empty.textContent = error.message; + } + } + + document.getElementById("showCreateProject").addEventListener("click", function () { + createPanel.hidden = false; document.getElementById("projectName").focus(); + }); + document.getElementById("closeCreateProject").addEventListener("click", function () { createPanel.hidden = true; setStatus(createStatus, ""); }); + projectSearch.addEventListener("input", renderProjects); + createForm.addEventListener("submit", async function (event) { + event.preventDefault(); + var submit = createForm.querySelector('[type="submit"]'); submit.disabled = true; setStatus(createStatus, "Creating project..."); + try { + var project = await request("/compute/api/projects", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ + name: document.getElementById("projectName").value.trim(), + description: document.getElementById("projectDescription").value.trim(), + visibility: document.getElementById("projectVisibility").value + }) + }); + var created = project.project || project; + window.location.assign("/compute/projects/" + encodeURIComponent(projectId(created))); + } catch (error) { setStatus(createStatus, error.message, "error"); submit.disabled = false; } + }); + + T.initToggle(document.getElementById("themeToggle")); + loadProjects(); loadInvitations(); +})(); diff --git a/server/revocompute/storage.py b/server/revocompute/storage.py new file mode 100644 index 00000000..833b5c22 --- /dev/null +++ b/server/revocompute/storage.py @@ -0,0 +1,144 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""Authoritative scope-aware storage resolution.""" +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +from typing import Any + +_STORAGE_KEY = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{2,119}\Z") +_TASK_ID = re.compile(r"[a-fA-F0-9]{32}\Z") + + +def path_is_within(base_dir: str, candidate: str) -> bool: + """Return whether a path is lexically and symlink-resolved within base.""" + base_abs, target_abs = os.path.abspath(base_dir), os.path.abspath(candidate) + try: + if os.path.commonpath([base_abs, target_abs]) != base_abs: + return False + except ValueError: + return False + probe, tail = target_abs, [] + while probe and not os.path.lexists(probe): + parent = os.path.dirname(probe) + if parent == probe: + break + tail.append(os.path.basename(probe)) + probe = parent + resolved = os.path.realpath(os.path.join(probe, *reversed(tail))) + try: + return os.path.commonpath([os.path.realpath(base_abs), resolved]) == os.path.realpath(base_abs) + except ValueError: + return False + + +def safe_join(base_dir: str, *parts: str) -> str: + candidate = os.path.abspath(os.path.join(base_dir, *parts)) + if not path_is_within(base_dir, candidate): + raise ValueError("path escapes configured storage root") + return candidate + + +def _sha256_file(path: str) -> str: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +class StorageResolver: + """Resolve every task path exclusively from its immutable scope identity.""" + + def __init__(self, results_dir: str, workspace_dir: str): + self.results_dir = os.path.abspath(results_dir) + self.workspace_dir = os.path.abspath(workspace_dir) + + @staticmethod + def _scope_parts(task: dict[str, Any]) -> tuple[str, str, str]: + scope_type = str(task.get("scope_type") or "") + storage_key = str(task.get("storage_key") or "") + task_id = str(task.get("md5sum") or "").lower() + if scope_type not in {"personal", "project"}: + raise ValueError("invalid task scope type") + if not _STORAGE_KEY.fullmatch(storage_key): + raise ValueError("invalid task scope storage key") + if not _TASK_ID.fullmatch(task_id): + raise ValueError("invalid task id") + return scope_type, storage_key, task_id + + def get_scope_root(self, scope_type: str, storage_key: str, *, inputs: bool = False) -> str: + if scope_type not in {"personal", "project"} or not _STORAGE_KEY.fullmatch(storage_key): + raise ValueError("invalid scope identity") + base = self.workspace_dir if inputs else self.results_dir + collection = "users" if scope_type == "personal" else "projects" + return safe_join(base, collection, storage_key) + + def get_task_root(self, task: dict[str, Any]) -> str: + scope_type, storage_key, task_id = self._scope_parts(task) + return safe_join(self.get_scope_root(scope_type, storage_key), "tasks", task_id) + + def get_input_root(self, task: dict[str, Any]) -> str: + scope_type, storage_key, task_id = self._scope_parts(task) + return safe_join(self.get_scope_root(scope_type, storage_key, inputs=True), "tasks", task_id) + + def get_output_root(self, task: dict[str, Any]) -> str: + return self.get_task_root(task) + + def get_manifest_path(self, task: dict[str, Any]) -> str: + return safe_join(self.get_task_root(task), "manifest.json") + + def get_archive_path(self, task: dict[str, Any]) -> str: + task_id = str(task.get("md5sum") or "").lower() + if not _TASK_ID.fullmatch(task_id): + raise ValueError("invalid task id") + return safe_join(os.path.dirname(self.get_task_root(task)), f"{task_id}_results.zip") + + scope_root = get_scope_root + + def task_root(self, scope_type: str, storage_key: str, task_id: str) -> str: + return self.get_task_root({"scope_type": scope_type, "storage_key": storage_key, "md5sum": task_id}) + + manifest_path = get_manifest_path + + def resolve_artifact(self, task: dict[str, Any], relative_path: str) -> dict[str, Any] | None: + normalized = relative_path.replace("\\", "/") + parts = normalized.split("/") + if not normalized or normalized.startswith("/") or any(part in {"", ".", ".."} for part in parts): + return None + try: + with open(self.get_manifest_path(task), encoding="utf-8") as handle: + manifest = json.load(handle) + artifact = next(item for item in manifest.get("artifacts", []) if item.get("path") == normalized) + path = safe_join(self.get_task_root(task), *parts) + except (AttributeError, OSError, ValueError, StopIteration, TypeError): + return None + if not os.path.isfile(path) or os.path.islink(path): + return None + digest = _sha256_file(path) + size = os.path.getsize(path) + if artifact.get("sha256") and artifact["sha256"] != digest: + return None + if artifact.get("size") is not None and artifact["size"] != size: + return None + return { + **artifact, + "path": normalized, + "physical_path": path, + "sha256": digest, + "size": size, + "type": artifact.get("type") or artifact.get("media_type"), + } + + +def snapshot_artifact(source: dict[str, Any], destination: str) -> dict[str, Any]: + os.makedirs(os.path.dirname(destination), exist_ok=True) + shutil.copyfile(source["physical_path"], destination) + os.chmod(destination, 0o440) + return {key: source[key] for key in ("path", "sha256", "size", "type") if key in source} diff --git a/server/revocompute/task_runtime.py b/server/revocompute/task_runtime.py index 8be6d292..ef926519 100644 --- a/server/revocompute/task_runtime.py +++ b/server/revocompute/task_runtime.py @@ -45,6 +45,7 @@ resolve_expected_files, storyboard_declaration, ) +from revocompute.storage import StorageResolver from revocompute.task_types import get as _get_task_type from revocompute.task_types import get_job_executor as _get_job_executor from revocompute.task_types import load_registry as _load_task_registry @@ -159,11 +160,23 @@ def _local_user_identity() -> str: def _task_zip_path(task: Any) -> str: - raw_task_id = task if isinstance(task, str) else task["md5sum"] - task_id = _normalize_task_id(raw_task_id) - if task_id is None: - raise ValueError(f"Invalid task id for result archive: {raw_task_id!r}") - return _safe_join(CONFIG.results_folder, f"{task_id}_results.zip") + if isinstance(task, str): + stored = task_store.get_task(task) + if stored is None: + raise ValueError(f"Unknown task id for result archive: {task!r}") + return _storage().get_archive_path(stored) + else: + return _storage().get_archive_path(task) + + +def _task_result_dir(task: dict[str, Any]) -> str: + """Resolve the authoritative output root from the task scope.""" + return _storage().get_task_root(task) + + +def _storage() -> StorageResolver: + """Build from current config so tests and controlled reloads stay isolated.""" + return StorageResolver(CONFIG.results_folder, CONFIG.workspace_folder) def _virtual_upload_path(filename: str) -> str: @@ -221,7 +234,10 @@ def _sanitize_task_error(task: dict[str, Any], error: Any) -> str | None: message = message.replace(file_path, _virtual_upload_path(task.get("filename", "unknown.fasta"))) if CONFIG.server_dir and CONFIG.server_dir in message: message = message.replace(CONFIG.server_dir, "") - result_dir = str(task.get("result_dir") or "") + try: + result_dir = _task_result_dir(task) + except ValueError: + result_dir = "" if result_dir and result_dir in message: message = message.replace(result_dir, "") return message @@ -686,7 +702,7 @@ def _finalize_results_manifest( """Atomically publish the immutable scientific result record for a task.""" if execution_state not in {"completed", "failed"}: raise ValueError("execution_state must be completed or failed") - result_dir = os.path.abspath(task["result_dir"]) + result_dir = _task_result_dir(task) os.makedirs(result_dir, exist_ok=True) try: task_type, _ = _get_task_type(task.get("task_type", "gremlin")) @@ -762,7 +778,7 @@ def _finalize_results_manifest( def _build_results_archive(task: dict) -> str: """Build an optional ZIP from the artifacts published in the manifest.""" zip_filename = _task_zip_path(task) - result_dir = os.path.abspath(task["result_dir"]) + result_dir = _task_result_dir(task) manifest_path = _safe_join(result_dir, "manifest.json") try: with open(manifest_path, encoding="utf-8") as handle: @@ -790,8 +806,9 @@ def _build_results_archive(task: dict) -> str: def _finalize_failed_results(task: dict, error: Any, *, finished_at: float) -> None: - result_dir = task.get("result_dir") - if not result_dir: + try: + result_dir = _task_result_dir(task) + except ValueError: return try: os.makedirs(result_dir, exist_ok=True) @@ -876,17 +893,13 @@ def _cleanup_task_workspace(task: dict[str, Any]) -> None: state. Results live in the separate results folder and are untouched; only the immutable input snapshot and staging area are removed, so finished tasks no longer hold duplicate input copies on disk.""" - username = str(task.get("username") or "").strip() - md5sum = str(task.get("md5sum") or "") - if not username or not md5sum: - return try: - workspace_dir = _safe_join(CONFIG.workspace_folder, username, md5sum) + workspace_dir = _storage().get_input_root(task) except ValueError: return if os.path.isdir(workspace_dir): shutil.rmtree(workspace_dir, ignore_errors=True) - logging.info("Cleaned up workspace %s for finished task %s", workspace_dir, md5sum) + logging.info("Cleaned up workspace %s for finished task %s", workspace_dir, task.get("md5sum")) def _entities_from_input_form(task: dict[str, Any]) -> list[dict]: @@ -910,8 +923,9 @@ def _capture_debug_submission(task: dict[str, Any], entities: list[dict], params plus each input snapshot copied to its user-facing path under ``debug/inputs/``. Any failure only logs a warning — debug capture must never fail a job finalization.""" - result_dir = str(task.get("result_dir") or "") - if not result_dir: + try: + result_dir = _task_result_dir(task) + except ValueError: return try: debug_dir = _safe_join(result_dir, "debug") @@ -1018,7 +1032,7 @@ def _execute_compute_task(md5sum: str, task_type: str = "gremlin", params: dict _record_failure(md5sum, task, time.time(), "", f"Unknown task type: {task_type!r}") return - output_dir = task["result_dir"] + output_dir = _task_result_dir(task) # Parse entities from the input_form JSON blob raw_form = task.get("input_form") @@ -1338,7 +1352,7 @@ def _recover_orphaned_tasks() -> int: from revocompute.task_types import get as _gt tt, runner = _gt(task_type) - job = DockerJob(md5sum, tt, runner, [], task["result_dir"]) + job = DockerJob(md5sum, tt, runner, [], _task_result_dir(task)) if job.reconnect(container_id): logging.info("Recovery: reconnected Docker %s for %s", container_id, md5sum) threading.Thread( diff --git a/server/revocompute/templates/create_task.html b/server/revocompute/templates/create_task.html index d0fdc902..e3bc6616 100644 --- a/server/revocompute/templates/create_task.html +++ b/server/revocompute/templates/create_task.html @@ -21,6 +21,7 @@ @@ -62,6 +63,16 @@

Create Compute Task

+
+

Task ownership

Run in

+
+ +
+ +
diff --git a/server/revocompute/templates/dashboard.html b/server/revocompute/templates/dashboard.html index 530ceaf9..702e04fb 100644 --- a/server/revocompute/templates/dashboard.html +++ b/server/revocompute/templates/dashboard.html @@ -22,6 +22,7 @@

REvoCompute Task Dashboard

New Task + Projects GitHub diff --git a/server/revocompute/templates/project.html b/server/revocompute/templates/project.html new file mode 100644 index 00000000..3b7387d8 --- /dev/null +++ b/server/revocompute/templates/project.html @@ -0,0 +1,81 @@ + + + + + +REvoDesign | Project + + + + + + + + +
+
+ + + Projects + +
+ Dashboard + +
+
+ +
+

Project scope

Loading project...

+ +
+ + + +
+ +
+
+
Tasks-
+
Members-
+
Your roleReader
+
+

Latest work

Recent tasks

+
+ + + + + + +
+ + + + + + diff --git a/server/revocompute/templates/projects.html b/server/revocompute/templates/projects.html new file mode 100644 index 00000000..baa1a36b --- /dev/null +++ b/server/revocompute/templates/projects.html @@ -0,0 +1,65 @@ + + + + + +REvoDesign | Projects + + + + + + + + +
+
+ + + REvoCompute + +
+ New task + Dashboard + +
+
+ +
+
+

Scientific collaboration

+

Projects

+
+ +
+ + + +
+

Personal and shared scopes

Your projects

+
+ +
+ +
+

Pending action

Invitations

+
+ +
+
+ + + + + diff --git a/server/tests/conftest.py b/server/tests/conftest.py index cb9dd8a0..1c88daf9 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -254,6 +254,46 @@ def _admin_client_auth(module, username: str = "sysadmin") -> dict[str, str]: return {"Authorization": f"Bearer {generate_token(user['id'])}"} +def _personal_task_scope(module, username: str) -> dict[str, str]: + """Return a complete fresh-schema Personal scope for a test task.""" + database = module.app.config["user_db"] + user = database.get_user_by_username(username) + if user is None: + user = database.create_user( + username=username, + email=f"{username}@test.local", + password="test_password", + registration_status="approved", + user_status="active", + ) + return {"scope_type": "personal", "scope_id": str(user["id"]), "storage_key": user["storage_key"]} + + +def _relocate_task_artifacts(module, md5sum: str, source_dir: Path | str, scope: dict[str, str]) -> Path: + """Place fixture output at the same resolver-owned path production uses.""" + source = Path(source_dir) + task = {"md5sum": md5sum, **scope} + resolver = module.app.config["storage_resolver"] + destination = Path(resolver.get_task_root(task)) + if source.resolve() != destination.resolve(): + destination.parent.mkdir(parents=True, exist_ok=True) + if source.exists(): + shutil.copytree(source, destination, dirs_exist_ok=True) + if source.is_symlink(): + source.unlink() + else: + shutil.rmtree(source) + source.symlink_to(destination, target_is_directory=True) + else: + destination.mkdir(parents=True, exist_ok=True) + old_archive = Path(module.app.config["RESULTS_FOLDER"]) / f"{md5sum}_results.zip" + archive = Path(resolver.get_archive_path(task)) + if old_archive.is_file() and old_archive != archive: + archive.parent.mkdir(parents=True, exist_ok=True) + old_archive.replace(archive) + return destination + + def _upsert_task_for_user( module, md5sum: str, @@ -265,11 +305,12 @@ def _upsert_task_for_user( status: str = "finished", run_stage: str | None = None, ) -> None: + scope = _personal_task_scope(module, username) + _relocate_task_artifacts(module, md5sum, result_dir, scope) module.task_store.upsert_task( md5sum, filename=filename, file_path=str(file_path), - result_dir=str(result_dir), uploaded_at=time.time(), started_at=time.time(), finished_at=time.time(), @@ -279,7 +320,9 @@ def _upsert_task_for_user( source_ip="127.0.0.1", user_agent="pytest", username=username, + submitted_by_user_id=int(scope["scope_id"]), run_stage=run_stage, + **scope, ) @@ -288,17 +331,20 @@ def _insert_pending_task(module, result_dir: Path, filename: str = "input.fasta" fasta_path = result_dir / filename fasta_path.write_text(">test\nACDE\n", encoding="utf-8") md5sum = uuid.uuid4().hex + scope = _personal_task_scope(module, "tester") + _relocate_task_artifacts(module, md5sum, result_dir, scope) module.task_store.upsert_task( md5sum, filename=filename, file_path=str(fasta_path), - result_dir=str(result_dir), uploaded_at=time.time(), status="pending", is_binary=0, source_ip="127.0.0.1", user_agent="pytest", username="tester", + submitted_by_user_id=int(scope["scope_id"]), + **scope, ) return md5sum diff --git a/server/tests/test_artifact_references.py b/server/tests/test_artifact_references.py new file mode 100644 index 00000000..f6b51f42 --- /dev/null +++ b/server/tests/test_artifact_references.py @@ -0,0 +1,271 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""End-to-end authorization, snapshot, and provenance tests for @ references.""" + +from __future__ import annotations + +import hashlib +import json +import time +import uuid +from pathlib import Path + +import pytest +from conftest import _load_pssm_module, _test_client_auth + + +@pytest.fixture +def module(monkeypatch, tmp_path): + loaded = _load_pssm_module( + monkeypatch, + tmp_path, + extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678", "ENABLED_TASKRUNNERS": "gremlin"}, + ) + + class Queued: + id = "artifact-reference-test" + + monkeypatch.setattr(loaded.run_compute_task, "apply_async", lambda *args, **kwargs: Queued()) + return loaded + + +def _user(module, username): + headers = _test_client_auth(module, username) + return module.app.config["user_db"].get_user_by_username(username), headers + + +def _source_task(module, owner, *, project=None, status="finished", publish=True, symlink=False): + task_id = uuid.uuid4().hex + scope = { + "scope_type": "project" if project else "personal", + "scope_id": str(project["id"] if project else owner["id"]), + "storage_key": project["storage_key"] if project else owner["storage_key"], + } + task = {"md5sum": task_id, **scope} + root = Path(module.app.config["storage_resolver"].get_task_root(task)) + root.mkdir(parents=True) + artifact = root / "models" / "source.fasta" + artifact.parent.mkdir() + content = b">source\nACDEFG\n" + if symlink: + outside = root.parent / "outside.fasta" + outside.write_bytes(content) + artifact.symlink_to(outside) + else: + artifact.write_bytes(content) + if publish: + manifest = { + "artifacts": [ + { + "path": "models/source.fasta", + "sha256": hashlib.sha256(content).hexdigest(), + "size": len(content), + "media_type": "text/plain", + "role": "artifact", + } + ] + } + else: + manifest = {"artifacts": []} + (root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + module.task_store.upsert_task( + task_id, + filename="input.fasta", + file_path=str(artifact), + uploaded_at=time.time(), + started_at=time.time(), + finished_at=time.time() if status == "finished" else None, + status=status, + is_binary=0, + username=owner["username"], + submitted_by_user_id=int(owner["id"]), + task_type="gremlin", + **scope, + ) + return module.task_store.get_task(task_id), artifact + + +def _submit_reference(module, headers, source, *, project=None, path="models/source.fasta"): + data = { + "task_type": "gremlin", + "artifact_references": f"@{source['md5sum']}/{path}", + "scope_type": "project" if project else "personal", + } + if project: + data["scope_id"] = str(project["id"]) + return module.app.test_client().post( + "/compute/api/post", headers=headers, data=data, content_type="multipart/form-data" + ) + + +def _join_project(store, project, user, role): + invitation = store.invite(project["id"], user["id"], project["owner_user_id"], role) + assert store.respond_invitation(invitation["id"], user["id"], True) + + +def _disable_cancel_dispatch(module, monkeypatch): + route_globals = module.app.view_functions["cancel_task"].__wrapped__.__globals__ + monkeypatch.setattr(route_globals["cancel_compute_resources"], "delay", lambda *args, **kwargs: None) + + +def test_own_personal_artifact_becomes_immutable_snapshot_with_provenance(module): + alice, headers = _user(module, "alice") + source, source_path = _source_task(module, alice) + + response = _submit_reference(module, headers, source) + + assert response.status_code == 302, response.get_data(as_text=True) + task = module.task_store.get_task(response.headers["Location"].rsplit("/", 1)[-1]) + snapshot = Path(module.app.config["storage_resolver"].get_input_root(task)) / "inputs" / "source.fasta" + assert snapshot.read_bytes() == source_path.read_bytes() + assert "/users/" in module.app.config["storage_resolver"].get_task_root(task) + provenance = json.loads(task["artifact_provenance"]) + assert provenance[0]["downstream_task_id"] == task["md5sum"] + assert provenance[0]["source_task_id"] == source["md5sum"] + assert provenance[0]["source_artifact_path"] == "models/source.fasta" + assert provenance[0]["sha256"] == hashlib.sha256(source_path.read_bytes()).hexdigest() + source_path.unlink() + assert snapshot.is_file() + + +def test_same_project_contributor_can_reuse_but_viewer_cannot(module): + alice, _ = _user(module, "alice") + bob, bob_headers = _user(module, "bob") + viewer, viewer_headers = _user(module, "viewer") + store = module.app.config["collaboration"] + project = store.create_project(alice["id"], "Shared Science") + project["owner_user_id"] = alice["id"] + _join_project(store, project, bob, "contributor") + _join_project(store, project, viewer, "viewer") + source, _ = _source_task(module, alice, project=project) + + allowed = _submit_reference(module, bob_headers, source, project=project) + denied = _submit_reference(module, viewer_headers, source, project=project) + + assert allowed.status_code == 302 + task = module.task_store.get_task(allowed.headers["Location"].rsplit("/", 1)[-1]) + assert task["scope_type"] == "project" + assert task["scope_id"] == str(project["id"]) + assert "/projects/" in module.app.config["storage_resolver"].get_task_root(task) + assert denied.status_code == 403 + + +def test_archived_project_artifacts_remain_reusable_but_project_is_frozen(module): + alice, _ = _user(module, "alice") + bob, bob_headers = _user(module, "bob") + viewer, viewer_headers = _user(module, "viewer") + store = module.app.config["collaboration"] + project = store.create_project(alice["id"], "Frozen Science") + project["owner_user_id"] = alice["id"] + _join_project(store, project, bob, "contributor") + _join_project(store, project, viewer, "viewer") + source, _ = _source_task(module, alice, project=project) + assert store.archive_project(project["id"]) + + reused_to_personal = _submit_reference(module, bob_headers, source) + viewer_denied = _submit_reference(module, viewer_headers, source) + project_submission_denied = _submit_reference(module, bob_headers, source, project=project) + + assert reused_to_personal.status_code == 302 + downstream = module.task_store.get_task(reused_to_personal.headers["Location"].rsplit("/", 1)[-1]) + assert downstream["scope_type"] == "personal" + assert json.loads(downstream["artifact_provenance"])[0]["source_scope_id"] == str(project["id"]) + assert viewer_denied.status_code == 403 + assert project_submission_denied.status_code == 403 + + +def test_task_mutation_uses_immutable_submitter_id_across_username_rename(module, monkeypatch): + alice, _ = _user(module, "alice") + bob, bob_headers = _user(module, "bob") + maintainer, maintainer_headers = _user(module, "maintainer") + viewer, viewer_headers = _user(module, "viewer") + store = module.app.config["collaboration"] + project = store.create_project(alice["id"], "Identity") + project["owner_user_id"] = alice["id"] + for user, role in ((bob, "contributor"), (maintainer, "maintainer"), (viewer, "viewer")): + _join_project(store, project, user, role) + + source, _ = _source_task(module, alice, project=project) + submitted = _submit_reference(module, bob_headers, source, project=project) + task_id = submitted.headers["Location"].rsplit("/", 1)[-1] + task = module.task_store.get_task(task_id) + assert task["submitted_by_user_id"] == bob["id"] + + _disable_cancel_dispatch(module, monkeypatch) + module.app.config["user_db"].update_user(bob["id"], username="bob-renamed") + impostor = module.app.config["user_db"].create_user( + "bob", + "bob-impostor@test.local", + "password", + registration_status="approved", + user_status="active", + ) + module.app.config["user_db"].verify_email(impostor["id"]) + from revocompute.auth import generate_token + + impostor_headers = {"Authorization": f"Bearer {generate_token(impostor['id'])}"} + impostor = module.app.config["user_db"].get_user_by_username("bob") + _join_project(store, project, impostor, "contributor") + client = module.app.test_client() + assert client.post(f"/compute/api/cancel/{task_id}", headers=impostor_headers).status_code == 403 + assert client.post(f"/compute/api/cancel/{task_id}", headers=bob_headers).status_code == 200 + + other_source, _ = _source_task(module, alice, project=project) + other_task_id = _submit_reference(module, bob_headers, other_source, project=project).headers["Location"].rsplit( + "/", 1 + )[-1] + assert client.post(f"/compute/api/cancel/{other_task_id}", headers=viewer_headers).status_code == 403 + assert client.post(f"/compute/api/cancel/{other_task_id}", headers=maintainer_headers).status_code == 200 + + +def test_personal_task_mutation_remains_bound_to_user_id_after_rename(module, monkeypatch): + alice, alice_headers = _user(module, "alice") + source, _ = _source_task(module, alice) + submitted = _submit_reference(module, alice_headers, source) + task_id = submitted.headers["Location"].rsplit("/", 1)[-1] + _disable_cancel_dispatch(module, monkeypatch) + module.app.config["user_db"].update_user(alice["id"], username="alice-renamed") + + response = module.app.test_client().post(f"/compute/api/cancel/{task_id}", headers=alice_headers) + + assert response.status_code == 200 + assert module.task_store.get_task(task_id)["submitted_by_user_id"] == alice["id"] + + +def test_cross_user_and_cross_project_reuse_are_denied(module): + alice, _ = _user(module, "alice") + bob, bob_headers = _user(module, "bob") + personal, _ = _source_task(module, alice) + assert _submit_reference(module, bob_headers, personal).status_code == 403 + + store = module.app.config["collaboration"] + first = store.create_project(alice["id"], "First") + second = store.create_project(alice["id"], "Second") + for project in (first, second): + invitation = store.invite(project["id"], bob["id"], alice["id"], "contributor") + assert store.respond_invitation(invitation["id"], bob["id"], True) + source, _ = _source_task(module, alice, project=first) + assert _submit_reference(module, bob_headers, source, project=second).status_code == 403 + + +@pytest.mark.parametrize("condition", ["non_final", "not_manifest", "traversal", "absolute", "symlink"]) +def test_unusable_artifact_references_fail_closed(module, condition): + alice, headers = _user(module, "alice") + source, _ = _source_task( + module, + alice, + status="running" if condition == "non_final" else "finished", + publish=condition != "not_manifest", + symlink=condition == "symlink", + ) + path = { + "traversal": "../models/source.fasta", + "absolute": "/etc/passwd", + }.get(condition, "models/source.fasta") + + response = _submit_reference(module, headers, source, path=path) + + assert response.status_code in {400, 403} + assert b"/tmp/" not in response.data diff --git a/server/tests/test_browser_contracts.py b/server/tests/test_browser_contracts.py index 5c9c0404..d0f12dc0 100644 --- a/server/tests/test_browser_contracts.py +++ b/server/tests/test_browser_contracts.py @@ -64,6 +64,8 @@ def test_js_modules_load_in_correct_order() -> None: "viewer-shell.js", "task-results.js", "create-task.js", + "projects.js", + "project.js", ): result = subprocess.run( ["node", "--check", str(js_dir / filename)], diff --git a/server/tests/test_debug_capture.py b/server/tests/test_debug_capture.py index c0a0daad..38b07f72 100644 --- a/server/tests/test_debug_capture.py +++ b/server/tests/test_debug_capture.py @@ -53,8 +53,14 @@ def rt(monkeypatch, tmp_path): def _make_task(rt, relative_paths=("query.fasta",)): md5 = "a" * 32 - ws = Path(rt.CONFIG.workspace_folder) - snapshot_root = ws / "alice" / md5 / "inputs" + identity = { + "md5sum": md5, + "scope_type": "personal", + "scope_id": "1", + "storage_key": "alice-abcdef", + } + resolver = rt.StorageResolver(rt.CONFIG.results_folder, rt.CONFIG.workspace_folder) + snapshot_root = Path(resolver.get_input_root(identity)) / "inputs" snapshot_root.mkdir(parents=True) entities = [] for index, relative_path in enumerate(relative_paths): @@ -69,21 +75,21 @@ def _make_task(rt, relative_paths=("query.fasta",)): "value": Path(relative_path).name, "verified_value": relative_path, "relative_path": relative_path, - "mounted": f"/mnt/revocompute/alice/inputs/{relative_path}", + "mounted": f"/mnt/revocompute/alice-abcdef/inputs/{relative_path}", "hash": hashlib.sha256(content).hexdigest(), "snapshot_path": str(snapshot), "snapshot_root": str(snapshot_root), - "workspace_key": "alice", + "workspace_key": "alice-abcdef", } ) entities.append({"name": "max_iter", "type": "int", "value": 5, "verified_value": 5}) - result_dir = Path(rt.CONFIG.results_folder) / md5 + result_dir = Path(resolver.get_task_root(identity)) result_dir.mkdir(parents=True) task = { "md5sum": md5, "task_type": "gremlin", "username": "alice", - "result_dir": str(result_dir), + **identity, "input_form": json.dumps( { "user": "alice", @@ -99,6 +105,14 @@ def _make_task(rt, relative_paths=("query.fasta",)): return task, entities +def _result_root(rt, task): + return Path(rt.StorageResolver(rt.CONFIG.results_folder, rt.CONFIG.workspace_folder).get_task_root(task)) + + +def _input_root(rt, task): + return Path(rt.StorageResolver(rt.CONFIG.results_folder, rt.CONFIG.workspace_folder).get_input_root(task)) + + class _FakeTaskStore: def __init__(self, task): self.task = task @@ -122,7 +136,7 @@ def test_capture_writes_submission_json_and_input_copies(rt, tmp_path): task, entities = _make_task(rt) rt._capture_debug_submission(task, entities) - debug_dir = tmp_path / "server" / "results" / task["md5sum"] / "debug" + debug_dir = _result_root(rt, task) / "debug" assert (debug_dir / "inputs" / "query.fasta").read_bytes() == b">seq0\nACDE\n" submission = json.loads((debug_dir / "submission.json").read_text(encoding="utf-8")) @@ -140,7 +154,7 @@ def test_capture_keeps_nested_user_facing_paths(rt): task, entities = _make_task(rt, relative_paths=("sub/dir/input.fa",)) rt._capture_debug_submission(task, entities) - debug_dir = Path(task["result_dir"]) / "debug" + debug_dir = _result_root(rt, task) / "debug" assert (debug_dir / "inputs" / "sub" / "dir" / "input.fa").read_bytes() == b">seq0\nACDE\n" submission = json.loads((debug_dir / "submission.json").read_text(encoding="utf-8")) assert submission["files"][0]["name"] == "sub/dir/input.fa" @@ -150,7 +164,7 @@ def test_capture_uses_explicit_params_argument(rt): task, entities = _make_task(rt) rt._capture_debug_submission(task, entities, {"overrides": "raw"}) - debug_dir = Path(task["result_dir"]) / "debug" + debug_dir = _result_root(rt, task) / "debug" submission = json.loads((debug_dir / "submission.json").read_text(encoding="utf-8")) assert submission["params"] == {"overrides": "raw"} @@ -168,7 +182,7 @@ def test_capture_skips_path_traversal(rt): ) rt._capture_debug_submission(task, entities) - debug_dir = Path(task["result_dir"]) / "debug" + debug_dir = _result_root(rt, task) / "debug" assert not (debug_dir / "inputs" / "evil.fa").exists() submission = json.loads((debug_dir / "submission.json").read_text(encoding="utf-8")) assert all(fe["name"] != "../evil.fa" for fe in submission["files"]) @@ -189,7 +203,7 @@ def test_capture_skips_snapshot_outside_workspace(rt, tmp_path): ) rt._capture_debug_submission(task, entities) - debug_dir = Path(task["result_dir"]) / "debug" + debug_dir = _result_root(rt, task) / "debug" assert not (debug_dir / "inputs" / "stolen.bin").exists() @@ -199,7 +213,7 @@ def test_capture_never_raises_on_missing_snapshot(rt): # Must not raise — the job finalization keeps going regardless. rt._capture_debug_submission(task, entities) - debug_dir = Path(task["result_dir"]) / "debug" + debug_dir = _result_root(rt, task) / "debug" submission = json.loads((debug_dir / "submission.json").read_text(encoding="utf-8")) assert submission["files"] == [] @@ -216,8 +230,8 @@ def test_record_failure_captures_before_workspace_cleanup(rt, monkeypatch): assert fake_store.updates[-1]["status"] == "failed" # Workspace was cleaned up, so the debug copy proves capture ran first. - assert not (Path(rt.CONFIG.workspace_folder) / "alice" / task["md5sum"]).exists() - result_dir = Path(task["result_dir"]) + assert not _input_root(rt, task).exists() + result_dir = _result_root(rt, task) debug_dir = result_dir / "debug" assert (debug_dir / "submission.json").is_file() assert (debug_dir / "inputs" / "query.fasta").read_bytes() == b">seq0\nACDE\n" @@ -234,8 +248,8 @@ def test_finalize_after_poll_publishes_debug_files_in_manifest(rt, monkeypatch): rt._finalize_after_poll(task["md5sum"], task, _FakeTaskType(), rt.JobState.COMPLETED) assert fake_store.updates[-1]["status"] == "finished" - assert not (Path(rt.CONFIG.workspace_folder) / "alice" / task["md5sum"]).exists() - manifest = json.loads((Path(task["result_dir"]) / "manifest.json").read_text(encoding="utf-8")) + assert not _input_root(rt, task).exists() + manifest = json.loads((_result_root(rt, task) / "manifest.json").read_text(encoding="utf-8")) artifact_paths = {artifact["path"] for artifact in manifest["artifacts"]} assert "debug/submission.json" in artifact_paths assert "debug/inputs/query.fasta" in artifact_paths diff --git a/server/tests/test_project_routes.py b/server/tests/test_project_routes.py new file mode 100644 index 00000000..5eb84688 --- /dev/null +++ b/server/tests/test_project_routes.py @@ -0,0 +1,364 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""HTTP authorization and lifecycle coverage for Project scope.""" + +from __future__ import annotations + +import hashlib +import json +import time +import uuid +from pathlib import Path + +from conftest import _load_pssm_module, _test_client_auth + + +def _module(monkeypatch, tmp_path): + return _load_pssm_module( + monkeypatch, + tmp_path, + extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678", "ENABLED_TASKRUNNERS": "gremlin"}, + ) + + +def _join(store, project_id, owner_id, user_id, role): + invitation = store.invite(project_id, user_id, owner_id, role) + assert store.respond_invitation(invitation["id"], user_id, True) + + +def _insert_project_task(module, project, submitter, *, status="pending"): + task_id = uuid.uuid4().hex + module.task_store.upsert_task( + task_id, + filename="input.fasta", + file_path="/display-only/input.fasta", + uploaded_at=time.time(), + status=status, + is_binary=0, + username=submitter["username"], + submitted_by_user_id=int(submitter["id"]), + task_type="gremlin", + scope_type="project", + scope_id=str(project["id"]), + storage_key=project["storage_key"], + ) + return module.task_store.get_task(task_id) + + +def _publish_result_manifest(module, task): + root = Path(module.app.config["storage_resolver"].get_task_root(task)) + root.mkdir(parents=True) + artifacts = [] + for path, role, content in ( + ("models/model.pdb", "primary", b"ATOM\n"), + ("logs/run.log", "diagnostic", b"private diagnostics\n"), + ("provenance/source.json", "provenance", b"{}\n"), + ): + target = root / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + artifacts.append( + { + "path": path, + "role": role, + "media_type": "text/plain", + "size": len(content), + "sha256": hashlib.sha256(content).hexdigest(), + "preview": "text", + "cardinality": "one", + } + ) + manifest = { + "artifacts": artifacts, + "views": [ + {"type": "structure", "sources": {"structures": ["models/model.pdb"]}}, + {"type": "text", "sources": {"logs": ["logs/run.log"]}}, + ], + "result": {"files": {"model": [artifacts[0]], "log": [artifacts[1]]}}, + } + (root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + + +def test_project_visibility_discovery_and_private_lookup(monkeypatch, tmp_path): + module = _module(monkeypatch, tmp_path) + owner_headers = _test_client_auth(module, "owner") + outsider_headers = _test_client_auth(module, "outsider") + owner = module.app.config["user_db"].get_user_by_username("owner") + store = module.app.config["collaboration"] + private = store.create_project(owner["id"], "Private") + internal = store.create_project(owner["id"], "Internal", visibility="internal") + public = store.create_project(owner["id"], "Public", visibility="public") + client = module.app.test_client() + + anonymous = client.get("/compute/api/projects").get_json()["projects"] + authenticated = client.get("/compute/api/projects", headers=outsider_headers).get_json()["projects"] + + assert {item["id"] for item in anonymous} == {public["id"]} + assert {item["id"] for item in authenticated} == {internal["id"], public["id"]} + assert client.get(f"/compute/api/projects/{private['id']}", headers=outsider_headers).status_code == 404 + assert client.get(f"/compute/api/projects/{public['id']}").status_code == 200 + assert client.get(f"/compute/projects/{public['id']}").status_code == 200 + assert client.get(f"/compute/projects/{private['id']}", headers=owner_headers).status_code == 200 + + +def test_duplicate_names_settings_invitation_and_archive_lifecycle(monkeypatch, tmp_path): + module = _module(monkeypatch, tmp_path) + owner_headers = _test_client_auth(module, "owner") + invited_headers = _test_client_auth(module, "invited") + invited = module.app.config["user_db"].get_user_by_username("invited") + client = module.app.test_client() + + first = client.post( + "/compute/api/projects", + headers=owner_headers, + json={"name": "Same Name", "visibility": "private"}, + ) + second = client.post( + "/compute/api/projects", + headers=owner_headers, + json={"name": "Same Name", "visibility": "private"}, + ) + assert first.status_code == second.status_code == 201 + assert first.get_json()["slug"] != second.get_json()["slug"] + project = first.get_json() + storage_key = project["storage_key"] + + updated = client.patch( + f"/compute/api/projects/{project['id']}", + headers=owner_headers, + json={"name": "Renamed", "description": "Science", "visibility": "internal"}, + ) + assert updated.status_code == 200 + assert updated.get_json()["storage_key"] == storage_key + + invitation = client.post( + f"/compute/api/projects/{project['id']}/invitations", + headers=owner_headers, + json={"user_id": invited["id"], "role": "contributor"}, + ) + assert invitation.status_code == 201 + accepted = client.post( + f"/compute/api/invitations/{invitation.get_json()['id']}", + headers=invited_headers, + json={"action": "accept"}, + ) + assert accepted.status_code == 200 + assert module.app.config["collaboration"].can_submit_task(project["id"], invited["id"]) + + archived = client.delete(f"/compute/api/projects/{project['id']}", headers=owner_headers) + assert archived.status_code == 200 + archived_project = client.get(f"/compute/api/projects/{project['id']}", headers=owner_headers) + assert archived_project.status_code == 200 + assert archived_project.get_json()["capabilities"] == [ + "use_artifacts", + "view_project", + "view_results", + "view_tasks", + ] + assert client.patch( + f"/compute/api/projects/{project['id']}", headers=owner_headers, json={"name": "Not mutable"} + ).status_code == 404 + assert client.post( + f"/compute/api/projects/{project['id']}/invitations", + headers=owner_headers, + json={"user_id": invited["id"], "role": "viewer"}, + ).status_code == 403 + assert client.patch( + f"/compute/api/projects/{project['id']}/members/{invited['id']}", + headers=owner_headers, + json={"role": "viewer"}, + ).status_code == 403 + + +def test_project_user_search_requires_invitation_capability_and_filters_candidates(monkeypatch, tmp_path): + module = _module(monkeypatch, tmp_path) + user_db = module.app.config["user_db"] + headers = {name: _test_client_auth(module, name) for name in ("owner", "maintainer", "contributor", "viewer")} + users = {name: user_db.get_user_by_username(name) for name in headers} + candidate_headers = _test_client_auth(module, "search-candidate") + del candidate_headers + member_headers = _test_client_auth(module, "search-member") + del member_headers + pending_headers = _test_client_auth(module, "search-pending") + del pending_headers + deleted_headers = _test_client_auth(module, "search-deleted") + del deleted_headers + candidate = user_db.get_user_by_username("search-candidate") + member = user_db.get_user_by_username("search-member") + pending = user_db.get_user_by_username("search-pending") + deleted = user_db.get_user_by_username("search-deleted") + user_db.update_user(deleted["id"], deleted=True) + + store = module.app.config["collaboration"] + project = store.create_project(users["owner"]["id"], "Search Scope") + for role in ("maintainer", "contributor", "viewer"): + _join(store, project["id"], users["owner"]["id"], users[role]["id"], role) + _join(store, project["id"], users["owner"]["id"], member["id"], "viewer") + store.invite(project["id"], pending["id"], users["owner"]["id"], "viewer") + client = module.app.test_client() + endpoint = f"/compute/api/projects/{project['id']}/users/search?q=search" + + assert client.get(endpoint).status_code == 401 + for role in ("contributor", "viewer"): + assert client.get(endpoint, headers=headers[role]).status_code == 403 + for role in ("owner", "maintainer"): + response = client.get(endpoint, headers=headers[role]) + assert response.status_code == 200 + assert response.get_json()["users"] == [ + {"id": candidate["id"], "username": "search-candidate", "display_name": "search-candidate"} + ] + assert client.get(f"/compute/api/projects/{project['id']}/users/search?q=s", headers=headers["owner"]).json == { + "users": [] + } + assert client.get("/compute/api/users/search?q=search", headers=headers["owner"]).status_code == 404 + + +def test_project_task_attribution_is_visible_only_to_members_and_admin(monkeypatch, tmp_path): + module = _module(monkeypatch, tmp_path) + owner_headers = _test_client_auth(module, "owner") + outsider_headers = _test_client_auth(module, "outsider") + admin_headers = _test_client_auth(module, "project-admin") + admin = module.app.config["user_db"].get_user_by_username("project-admin") + module.app.config["user_db"].update_user(admin["id"], role="admin") + owner = module.app.config["user_db"].get_user_by_username("owner") + store = module.app.config["collaboration"] + project = store.create_project(owner["id"], "Published Tasks", visibility="internal") + task = _insert_project_task(module, project, owner) + _insert_project_task(module, project, owner, status="deleted:cancel") + client = module.app.test_client() + endpoint = f"/compute/api/projects/{project['id']}/tasks" + + member_task = client.get(endpoint, headers=owner_headers).json["tasks"][0] + outsider_task = client.get(endpoint, headers=outsider_headers).json["tasks"][0] + admin_task = client.get(endpoint, headers=admin_headers).json["tasks"][0] + assert member_task["submitted_by"] == "owner" + assert outsider_task["submitted_by"] is None + assert admin_task["submitted_by"] == "owner" + assert "username" not in member_task | outsider_task | admin_task + assert len(client.get(endpoint, headers=owner_headers).json["tasks"]) == 1 + assert client.get(f"/compute/api/projects/{project['id']}", headers=owner_headers).json["task_count"] == 1 + + assert client.get(f"/compute/api/running/{task['md5sum']}", headers=outsider_headers).status_code == 202 + assert client.get(f"/compute/results/{task['md5sum']}", headers=outsider_headers).status_code == 200 + assert client.get(f"/compute/api/tasks/{task['md5sum']}/input", headers=outsider_headers).status_code == 403 + + store.update_project(project["id"], visibility="public") + assert client.get(endpoint).json["tasks"][0]["submitted_by"] is None + assert client.get(f"/compute/api/running/{task['md5sum']}").status_code == 202 + assert client.get(f"/compute/results/{task['md5sum']}").status_code == 200 + assert client.get(f"/compute/api/tasks/{task['md5sum']}/input").status_code == 401 + + +def test_public_project_failure_hides_diagnostics_from_non_members(monkeypatch, tmp_path): + module = _module(monkeypatch, tmp_path) + owner_headers = _test_client_auth(module, "owner") + owner = module.app.config["user_db"].get_user_by_username("owner") + project = module.app.config["collaboration"].create_project(owner["id"], "Public Failure", visibility="public") + task = _insert_project_task(module, project, owner, status="failed") + module.task_store.update_task(task["md5sum"], error="private runner path: /srv/results/secret") + client = module.app.test_client() + + public_payload = client.get(f"/compute/api/running/{task['md5sum']}").get_json() + owner_payload = client.get(f"/compute/api/running/{task['md5sum']}", headers=owner_headers).get_json() + + assert public_payload["error"] == "Task failed" + assert "private runner path" not in public_payload["error"] + assert owner_payload["error"] != "Task failed" + + +def test_project_visibility_drives_filtered_result_workspace(monkeypatch, tmp_path): + module = _module(monkeypatch, tmp_path) + owner_headers = _test_client_auth(module, "owner") + outsider_headers = _test_client_auth(module, "outsider") + owner = module.app.config["user_db"].get_user_by_username("owner") + store = module.app.config["collaboration"] + client = module.app.test_client() + + projects = { + visibility: store.create_project(owner["id"], visibility.title(), visibility=visibility) + for visibility in ("private", "internal", "public") + } + tasks = { + visibility: _insert_project_task(module, project, owner, status="finished") + for visibility, project in projects.items() + } + for task in tasks.values(): + _publish_result_manifest(module, task) + + for visibility, task in tasks.items(): + page = f"/compute/results/{task['md5sum']}" + api = f"/compute/api/results/{task['md5sum']}" + assert client.get(page, headers=owner_headers).status_code == 200 + owner_payload = client.get(api, headers=owner_headers).get_json() + assert owner_payload["archive"]["request_url"] + assert {artifact["role"] for artifact in owner_payload["artifacts"]} == { + "primary", + "diagnostic", + "provenance", + } + assert client.get( + f"/compute/api/results/{task['md5sum']}/artifacts/logs/run.log", headers=owner_headers + ).status_code == 200 + outsider_status = 403 if visibility == "private" else 200 + anonymous_status = 200 if visibility == "public" else 403 + assert client.get(page, headers=outsider_headers).status_code == outsider_status + assert client.get(page).status_code == anonymous_status + assert client.get(api, headers=outsider_headers).status_code == outsider_status + assert client.get(api).status_code == anonymous_status + + for headers in (outsider_headers, None): + task = tasks["internal" if headers else "public"] + request_headers = headers or {} + page = client.get(f"/compute/results/{task['md5sum']}", headers=request_headers) + payload = client.get(f"/compute/api/results/{task['md5sum']}", headers=request_headers).get_json() + artifact_root = f"/compute/api/results/{task['md5sum']}/artifacts/" + + assert '"owner"' not in page.text + assert "/display-only/input.fasta" not in page.text + assert {artifact["path"] for artifact in payload["artifacts"]} == {"models/model.pdb"} + assert payload["archive"] == {"ready": False, "request_url": None, "download_url": None} + assert payload["views"] == [{"type": "structure", "sources": {"structures": ["models/model.pdb"]}}] + assert client.get(artifact_root + "models/model.pdb", headers=request_headers).status_code == 200 + assert client.get(artifact_root + "logs/run.log", headers=request_headers).status_code == 404 + assert client.get(artifact_root + "provenance/source.json", headers=request_headers).status_code == 404 + assert client.post(f"/compute/api/results/{task['md5sum']}/archive", headers=request_headers).status_code in { + 401, + 403, + } + + +def test_personal_result_routes_remain_private(monkeypatch, tmp_path): + module = _module(monkeypatch, tmp_path) + owner_headers = _test_client_auth(module, "owner") + outsider_headers = _test_client_auth(module, "outsider") + admin_headers = _test_client_auth(module, "admin-reader") + user_db = module.app.config["user_db"] + owner = user_db.get_user_by_username("owner") + admin = user_db.get_user_by_username("admin-reader") + user_db.update_user(admin["id"], role="admin") + task_id = uuid.uuid4().hex + module.task_store.upsert_task( + task_id, + filename="personal.fasta", + file_path="/display-only/personal.fasta", + uploaded_at=time.time(), + status="finished", + is_binary=0, + username=owner["username"], + submitted_by_user_id=int(owner["id"]), + task_type="gremlin", + scope_type="personal", + scope_id=str(owner["id"]), + storage_key=owner["storage_key"], + ) + task = module.task_store.get_task(task_id) + _publish_result_manifest(module, task) + client = module.app.test_client() + + for suffix in (f"/compute/results/{task_id}", f"/compute/api/results/{task_id}", f"/compute/api/results/{task_id}/artifacts/models/model.pdb"): + assert client.get(suffix, headers=owner_headers).status_code == 200 + assert client.get(suffix, headers=admin_headers).status_code == 200 + assert client.get(suffix, headers=outsider_headers).status_code == 403 + assert client.get(suffix).status_code == 403 diff --git a/server/tests/test_projects.py b/server/tests/test_projects.py new file mode 100644 index 00000000..c7121861 --- /dev/null +++ b/server/tests/test_projects.py @@ -0,0 +1,183 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +from __future__ import annotations + +import time + +import pytest + +from revocompute.collaboration import CollaborationStore + + +@pytest.fixture +def store(tmp_path): + return CollaborationStore(str(tmp_path / "collaboration.sqlite3")) + + +def test_create_duplicate_slug_and_immutable_storage_identity(store): + first = store.create_project(1, "Protein Design") + second = store.create_project(2, "Protein Design") + assert first["slug"] == "protein-design" + assert second["slug"] == "protein-design-2" + assert first["storage_key"] != second["storage_key"] + storage_key = first["storage_key"] + assert store.rename_project(first["id"], "Renamed") + renamed = store.get_project(first["id"]) + assert renamed["storage_key"] == storage_key + assert renamed["slug"] == "protein-design" + with pytest.raises(ValueError, match="slug is immutable"): + store.rename_project(first["id"], "Again", slug="again") + + +def test_visibility_discovery_and_archival(store): + private = store.create_project(1, "Private") + internal = store.create_project(2, "Internal", visibility="internal") + public = store.create_project(3, "Public", visibility="public") + assert {p["id"] for p in store.list_projects(None, authenticated=False)} == {public["id"]} + assert {p["id"] for p in store.list_projects(99)} == {internal["id"], public["id"]} + assert store.can_submit_task(private["id"], 1) + assert not store.can_submit_task(internal["id"], 99) + assert store.archive_project(private["id"]) + assert store.archive_project(internal["id"]) + assert store.archive_project(public["id"]) + assert not store.archive_project(public["id"]) + assert store.can_view_project(public["id"], None, authenticated=False) + assert public["id"] in {project["id"] for project in store.list_projects(None, authenticated=False)} + assert {project["id"] for project in store.list_projects(99)} == {internal["id"], public["id"]} + assert {project["id"] for project in store.list_projects(1)} == {private["id"], internal["id"], public["id"]} + + +def test_effective_capabilities_for_members_and_outsiders(store): + private = store.create_project(1, "Private") + internal = store.create_project(2, "Internal", visibility="internal") + public = store.create_project(3, "Public", visibility="public") + viewer_invitation = store.invite(public["id"], 4, 3, "viewer") + assert store.respond_invitation(viewer_invitation["id"], 4, True) + assert "transfer_ownership" in store.capabilities(private["id"], 1) + assert store.capabilities(private["id"], 99) == [] + assert store.capabilities(internal["id"], 99) == ["view_project", "view_results", "view_tasks"] + assert store.capabilities(internal["id"], None, authenticated=False) == [] + assert store.capabilities(public["id"], None, authenticated=False) == [ + "view_project", + "view_results", + "view_tasks", + ] + assert store.archive_project(public["id"]) + assert store.capabilities(public["id"], 3) == [ + "use_artifacts", + "view_project", + "view_results", + "view_tasks", + ] + assert store.capabilities(public["id"], None, authenticated=False) == [ + "view_project", + "view_results", + "view_tasks", + ] + assert not store.can_submit_task(public["id"], 3) + assert store.capabilities(public["id"], 4) == ["view_project", "view_results", "view_tasks"] + assert not store.can_use_artifact(public["id"], 4) + + +def test_invitation_accept_decline_duplicate_and_expire(store): + project = store.create_project(1, "Team") + invitation = store.invite(project["id"], 2, 1, "contributor") + with pytest.raises(ValueError, match="pending invitation"): + store.invite(project["id"], 2, 1, "viewer") + assert not store.respond_invitation(invitation["id"], 3, True) + assert store.respond_invitation(invitation["id"], 2, True) + assert store.get_membership(project["id"], 2)["role"] == "contributor" + assert store.can_use_artifact(project["id"], 2) + with pytest.raises(ValueError, match="already a project member"): + store.invite(project["id"], 2, 1) + + declined = store.invite(project["id"], 3, 1) + assert store.respond_invitation(declined["id"], 3, False) + assert store.get_invitation(declined["id"])["status"] == "declined" + + expired = store.invite(project["id"], 4, 1, expires_at=time.time() + 0.01) + time.sleep(0.02) + assert not store.respond_invitation(expired["id"], 4, True) + assert store.get_invitation(expired["id"])["status"] == "expired" + + +def test_pending_invitation_cannot_add_member_after_archive(store): + project = store.create_project(1, "Frozen") + invitation = store.invite(project["id"], 2, 1, "contributor") + assert store.archive_project(project["id"]) + assert not store.respond_invitation(invitation["id"], 2, True) + assert store.get_invitation(invitation["id"])["status"] == "revoked" + assert store.get_membership(project["id"], 2) is None + assert not store.update_project(project["id"], name="Still frozen") + with pytest.raises(ValueError, match="project does not exist"): + store.invite(project["id"], 3, 1, "viewer") + + +def test_invitation_revoke_and_listing(store): + project = store.create_project(1, "Team") + invitation = store.invite(project["id"], 2, 1) + assert store.list_invitations(2) == [invitation] + assert store.revoke_invitation(invitation["id"]) + assert not store.revoke_invitation(invitation["id"]) + assert store.list_invitations(2) == [] + assert store.list_invitations(2, status="revoked")[0]["id"] == invitation["id"] + + +def test_list_project_invitations_filters_and_refreshes_expiry(store): + project = store.create_project(1, "Team") + other_project = store.create_project(3, "Other") + pending = store.invite(project["id"], 2, 1) + revoked = store.invite(project["id"], 3, 1) + assert store.revoke_invitation(revoked["id"]) + expiring = store.invite(project["id"], 4, 1, expires_at=time.time() + 0.01) + store.invite(other_project["id"], 5, 3) + time.sleep(0.02) + + invitations = store.list_project_invitations(project["id"]) + assert {item["id"] for item in invitations} == {pending["id"], revoked["id"], expiring["id"]} + assert store.get_invitation(expiring["id"])["status"] == "expired" + assert [item["id"] for item in store.list_project_invitations(project["id"], status="pending")] == [pending["id"]] + assert [item["id"] for item in store.list_project_invitations(project["id"], status="expired")] == [expiring["id"]] + with pytest.raises(ValueError, match="invalid invitation status"): + store.list_project_invitations(project["id"], status="unknown") + + +def test_member_roles_removal_and_ownership_transfer(store): + project = store.create_project(1, "Team") + invitation = store.invite(project["id"], 2, 1, "viewer") + assert store.respond_invitation(invitation["id"], 2, True) + assert not store.can_use_artifact(project["id"], 2) + assert store.set_member_role(project["id"], 2, "maintainer") + assert store.can_manage_members(project["id"], 2) + with pytest.raises(ValueError, match="transfer_ownership"): + store.set_member_role(project["id"], 2, "owner") + assert not store.remove_member(project["id"], 1) + assert store.transfer_ownership(project["id"], 1, 2) + assert store.get_membership(project["id"], 2)["role"] == "owner" + assert store.get_membership(project["id"], 1)["role"] == "maintainer" + assert store.remove_member(project["id"], 1) + + +def test_archival_wins_all_collaboration_mutations(store): + project = store.create_project(1, "Frozen") + invitation = store.invite(project["id"], 2, 1, "viewer") + assert store.archive_project(project["id"]) + assert not store.archive_project(project["id"]) + assert not store.set_member_role(project["id"], 1, "maintainer") + assert not store.remove_member(project["id"], 1) + assert not store.transfer_ownership(project["id"], 1, 2) + with pytest.raises(ValueError, match="project does not exist"): + store.invite(project["id"], 3, 1) + assert not store.respond_invitation(invitation["id"], 2, True) + assert store.get_membership(project["id"], 1)["role"] == "owner" + assert store.get_membership(project["id"], 2) is None + assert store.get_invitation(invitation["id"])["status"] == "revoked" + + +def test_reopen_existing_database_preserves_records(store): + project = store.create_project(1, "Persistent") + reopened = CollaborationStore(store.path) + assert reopened.get_project(project["id"])["storage_key"] == project["storage_key"] + assert reopened.get_membership(project["id"], 1)["role"] == "owner" diff --git a/server/tests/test_race_conditions.py b/server/tests/test_race_conditions.py index 7967d418..c3dcd40b 100644 --- a/server/tests/test_race_conditions.py +++ b/server/tests/test_race_conditions.py @@ -6,7 +6,6 @@ import io import json -import time import uuid import pytest @@ -30,7 +29,7 @@ def test_race_cancel_finished_task_rejected(monkeypatch, tmp_path): module = _load_pssm_module(monkeypatch, tmp_path, extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678"}) client = module.app.test_client() auth_header = _test_client_auth(module) - db = module.task_store + module.task_store # Simulate task that finished while user was about to click cancel result_dir = tmp_path / "race_cancel" @@ -38,17 +37,14 @@ def test_race_cancel_finished_task_rejected(monkeypatch, tmp_path): md5sum = uuid.uuid4().hex fasta_path = result_dir / "seqs.fasta" fasta_path.write_text(">race\nACDE\n", encoding="utf-8") - db.upsert_task( + _upsert_task_for_user( + module, md5sum, filename="seqs.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status="finished", - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status="finished", ) resp = client.post(f"/compute/api/cancel/{md5sum}", headers=auth_header) assert resp.status_code == 400 @@ -86,24 +82,21 @@ def test_race_cancel_already_cancelled_task(monkeypatch, tmp_path): module = _load_pssm_module(monkeypatch, tmp_path, extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678"}) client = module.app.test_client() auth_header = _test_client_auth(module) - db = module.task_store + module.task_store result_dir = tmp_path / "race_recancel" result_dir.mkdir(parents=True, exist_ok=True) md5sum = uuid.uuid4().hex fasta_path = result_dir / "seqs.fasta" fasta_path.write_text(">race\nACDE\n", encoding="utf-8") - db.upsert_task( + _upsert_task_for_user( + module, md5sum, filename="seqs.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status="cancelled", - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status="cancelled", ) resp = client.post(f"/compute/api/cancel/{md5sum}", headers=auth_header) assert resp.status_code == 400 @@ -115,24 +108,21 @@ def test_race_delete_already_cancelled_task(monkeypatch, tmp_path): module = _load_pssm_module(monkeypatch, tmp_path, extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678"}) client = module.app.test_client() auth_header = _test_client_auth(module) - db = module.task_store + module.task_store result_dir = tmp_path / "race_del_cancelled" result_dir.mkdir(parents=True, exist_ok=True) md5sum = uuid.uuid4().hex fasta_path = result_dir / "seqs.fasta" fasta_path.write_text(">race\nACDE\n", encoding="utf-8") - db.upsert_task( + _upsert_task_for_user( + module, md5sum, filename="seqs.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status="cancelled", - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status="cancelled", ) resp = client.delete(f"/compute/api/delete/{md5sum}", headers=auth_header) assert resp.status_code == 200 @@ -194,7 +184,7 @@ def test_race_status_polling_during_task_transition(monkeypatch, tmp_path): module = _load_pssm_module(monkeypatch, tmp_path, extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678"}) client = module.app.test_client() auth_header = _test_client_auth(module) - db = module.task_store + module.task_store result_dir = tmp_path / "poll_race" result_dir.mkdir(parents=True, exist_ok=True) @@ -205,17 +195,14 @@ def test_race_status_polling_during_task_transition(monkeypatch, tmp_path): # Simulate rapid polling across status transitions transitions = ["pending", "queued", "running", "finished"] for status in transitions: - db.upsert_task( + _upsert_task_for_user( + module, md5sum, filename="s.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status=status, - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status=status, ) resp = client.get(f"/compute/api/running/{md5sum}", headers=auth_header) valid_statuses = {200, 202} @@ -227,24 +214,21 @@ def test_race_batch_delete_duplicate_ids(monkeypatch, tmp_path): module = _load_pssm_module(monkeypatch, tmp_path, extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678"}) client = module.app.test_client() auth_header = _test_client_auth(module) - db = module.task_store + module.task_store result_dir = tmp_path / "dedup_race" result_dir.mkdir(parents=True, exist_ok=True) md5sum = uuid.uuid4().hex fasta_path = result_dir / "s.fasta" fasta_path.write_text(">x\nACDE\n", encoding="utf-8") - db.upsert_task( + _upsert_task_for_user( + module, md5sum, filename="s.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status="cancelled", - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status="cancelled", ) # Send the same md5sum 3 times resp = client.post( @@ -261,7 +245,7 @@ def test_race_batch_delete_with_nonexistent_and_duplicate(monkeypatch, tmp_path) module = _load_pssm_module(monkeypatch, tmp_path, extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678"}) client = module.app.test_client() auth_header = _test_client_auth(module) - db = module.task_store + module.task_store result_dir = tmp_path / "mixed_race" result_dir.mkdir(parents=True, exist_ok=True) @@ -270,17 +254,14 @@ def test_race_batch_delete_with_nonexistent_and_duplicate(monkeypatch, tmp_path) fasta_path = result_dir / "s.fasta" fasta_path.write_text(">x\nACDE\n", encoding="utf-8") for md5 in (valid_md5, another_md5): - db.upsert_task( + _upsert_task_for_user( + module, md5, filename="s.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status="cancelled", - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status="cancelled", ) nonexistent = "0" * 32 @@ -307,17 +288,14 @@ def test_race_cancel_concurrent_with_worker_completion(monkeypatch, tmp_path): md5sum = uuid.uuid4().hex fasta_path = result_dir / "s.fasta" fasta_path.write_text(">x\nACDE\n", encoding="utf-8") - db.upsert_task( + _upsert_task_for_user( + module, md5sum, filename="s.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status="cancelled", - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status="cancelled", ) # Simulate worker trying to write "finished" after user cancelled db.update_task(md5sum, status="finished", error=None) @@ -331,7 +309,7 @@ def test_race_status_polling_on_deleted_task(monkeypatch, tmp_path): module = _load_pssm_module(monkeypatch, tmp_path, extra_env={"RUNNER_UID": "1234", "RUNNER_GID": "5678"}) client = module.app.test_client() auth_header = _test_client_auth(module) - db = module.task_store + module.task_store result_dir = tmp_path / "deleted_poll" result_dir.mkdir(parents=True, exist_ok=True) @@ -340,17 +318,14 @@ def test_race_status_polling_on_deleted_task(monkeypatch, tmp_path): for d_status in ("deleted:cancel", "deleted:finshed"): fasta_path = result_dir / f"{d_status.replace(':', '_')}.fasta" fasta_path.write_text(">x\nACDE\n", encoding="utf-8") - db.upsert_task( + _upsert_task_for_user( + module, md5sum, filename="s.fasta", - file_path=str(fasta_path), - result_dir=str(result_dir), - uploaded_at=time.time(), - status=d_status, - is_binary=0, - source_ip="127.0.0.1", - user_agent="pytest", + file_path=fasta_path, + result_dir=result_dir, username="tester", + status=d_status, ) resp = client.get(f"/compute/api/running/{md5sum}", headers=auth_header) assert resp.status_code == 200 diff --git a/server/tests/test_schema_epoch.py b/server/tests/test_schema_epoch.py new file mode 100644 index 00000000..55c388a6 --- /dev/null +++ b/server/tests/test_schema_epoch.py @@ -0,0 +1,78 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""Fresh-schema bootstrap and fail-fast Project Scope epoch coverage.""" + +from __future__ import annotations + +import sqlite3 + +import pytest +from revocompute.auth import UserDatabase +from revocompute.collaboration import CollaborationDatabase +from revocompute.db import TaskDatabase + + +def test_fresh_empty_and_current_databases_boot_and_reopen(tmp_path): + user_path = tmp_path / "users.sqlite3" + task_path = tmp_path / "tasks.sqlite3" + collaboration_path = tmp_path / "collaboration.sqlite3" + user_path.touch() + task_path.touch() + collaboration_path.touch() + + users = UserDatabase(str(user_path)) + user = users.create_user("alice", "alice@example.test", "password") + tasks = TaskDatabase(str(task_path)) + collaboration = CollaborationDatabase(str(collaboration_path)) + project = collaboration.create_project(user["id"], "Science") + for database in (users, tasks, collaboration): + database.engine.dispose() + + reopened_users = UserDatabase(str(user_path)) + reopened_tasks = TaskDatabase(str(task_path)) + reopened_collaboration = CollaborationDatabase(str(collaboration_path)) + assert reopened_users.get_user(user["id"])["storage_key"] == user["storage_key"] + assert reopened_tasks.list_tasks() == [] + assert reopened_collaboration.get_project(project["id"])["storage_key"] == project["storage_key"] + + +def test_old_task_schema_fails_clearly_without_altering_columns(tmp_path): + path = tmp_path / "tasks.sqlite3" + conn = sqlite3.connect(path) + conn.execute( + "CREATE TABLE tasks (md5sum VARCHAR(32) PRIMARY KEY, filename VARCHAR NOT NULL, " + "scope_type VARCHAR NOT NULL, scope_id VARCHAR NOT NULL, storage_key VARCHAR NOT NULL, " + "artifact_provenance TEXT NOT NULL)" + ) + conn.execute("INSERT INTO tasks VALUES ('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'old.fasta', 'personal', '1', " + "'old-abcdef', '[]')") + conn.commit() + original_columns = {row[1] for row in conn.execute("PRAGMA table_info(tasks)")} + conn.close() + + with pytest.raises(RuntimeError, match="Incompatible task database.*submitted_by_user_id"): + TaskDatabase(str(path)) + + conn = sqlite3.connect(path) + assert {row[1] for row in conn.execute("PRAGMA table_info(tasks)")} == original_columns + assert conn.execute("SELECT filename FROM tasks").fetchone() == ("old.fasta",) + conn.close() + + +def test_partial_collaboration_schema_fails_clearly_without_bootstrapping_missing_tables(tmp_path): + path = tmp_path / "collaboration.sqlite3" + conn = sqlite3.connect(path) + conn.execute("CREATE TABLE projects (id INTEGER PRIMARY KEY, name VARCHAR NOT NULL)") + conn.execute("INSERT INTO projects (name) VALUES ('Old Project')") + conn.commit() + conn.close() + + with pytest.raises(RuntimeError, match="Incompatible collaboration database"): + CollaborationDatabase(str(path)) + + conn = sqlite3.connect(path) + assert {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} == {"projects"} + assert conn.execute("SELECT name FROM projects").fetchone() == ("Old Project",) + conn.close() diff --git a/server/tests/test_scoped_storage.py b/server/tests/test_scoped_storage.py new file mode 100644 index 00000000..0b522833 --- /dev/null +++ b/server/tests/test_scoped_storage.py @@ -0,0 +1,100 @@ +# Copyright (c) 2026 The REvoDesign Developers. +# Distributed under the terms of the GNU General Public License v3.0. +# SPDX-License-Identifier: GPL-3.0-only + +"""Scoped storage and immutable storage-identity tests.""" + +from __future__ import annotations + +import hashlib +import json +from pathlib import Path + +import pytest + +from revocompute.auth import UserDatabase +from revocompute.storage import StorageResolver + + +def _task(**overrides): + task = { + "md5sum": "a" * 32, + "scope_type": "personal", + "scope_id": "7", + "storage_key": "alice-k7m4qx", + "username": "alice", + } + task.update(overrides) + return task + + +def test_personal_and_project_roots_are_scope_derived(tmp_path): + resolver = StorageResolver(str(tmp_path / "results"), str(tmp_path / "workspaces")) + personal = resolver.get_task_root(_task()) + project = resolver.get_task_root(_task(scope_type="project", storage_key="science-m2d91p")) + + assert personal == str(tmp_path / "results" / "users" / "alice-k7m4qx" / "tasks" / ("a" * 32)) + assert project == str(tmp_path / "results" / "projects" / "science-m2d91p" / "tasks" / ("a" * 32)) + assert resolver.get_input_root(_task()) == str( + tmp_path / "workspaces" / "users" / "alice-k7m4qx" / "tasks" / ("a" * 32) + ) + + +def test_recorded_path_cannot_override_scoped_identity(tmp_path): + resolver = StorageResolver(str(tmp_path / "results"), str(tmp_path / "workspaces")) + task = _task(result_dir=str(tmp_path / "attacker-selected")) + assert resolver.get_task_root(task) != task["result_dir"] + + +def test_user_storage_keys_are_unique_and_immutable_across_rename(tmp_path): + path = tmp_path / "users.sqlite3" + db = UserDatabase(str(path)) + first = db.create_user("alice", "alice@example.test", "password123") + second = db.create_user("bob", "bob@example.test", "password123") + key = first["storage_key"] + other_key = second["storage_key"] + assert key != other_key + reopened = UserDatabase(str(path)) + assert reopened.get_user(first["id"])["storage_key"] == key + reopened.update_user(first["id"], username="alice_renamed") + assert reopened.get_user(first["id"])["storage_key"] == key + assert key.startswith("alice-") + + +def test_manifest_artifact_resolution_rejects_traversal_tampering_and_symlink_escape(tmp_path): + resolver = StorageResolver(str(tmp_path / "results"), str(tmp_path / "workspaces")) + task = _task() + root = Path(resolver.get_task_root(task)) + root.mkdir(parents=True) + artifact = root / "model.pdb" + content = b"ATOM\n" + artifact.write_bytes(content) + manifest = { + "artifacts": [ + { + "path": "model.pdb", + "sha256": hashlib.sha256(content).hexdigest(), + "size": len(content), + } + ] + } + (root / "manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + assert resolver.resolve_artifact(task, "model.pdb") is not None + for unsafe in ("../model.pdb", "../../etc/passwd", "/etc/passwd", "..\\model.pdb"): + assert resolver.resolve_artifact(task, unsafe) is None + artifact.write_bytes(b"changed") + assert resolver.resolve_artifact(task, "model.pdb") is None + artifact.unlink() + outside = tmp_path / "outside.pdb" + outside.write_bytes(content) + artifact.symlink_to(outside) + assert resolver.resolve_artifact(task, "model.pdb") is None + + +def test_invalid_storage_identity_fails_closed(tmp_path): + resolver = StorageResolver(str(tmp_path / "results"), str(tmp_path / "workspaces")) + with pytest.raises(ValueError, match="scope type"): + resolver.get_task_root({"md5sum": "a" * 32, "storage_key": "alice-abcdef"}) + for key in ("../alice", "/absolute", "a", "alice/other", "alice\\other"): + with pytest.raises(ValueError, match="storage key|scope storage|scope identity"): + resolver.get_task_root(_task(storage_key=key)) diff --git a/server/tests/test_security.py b/server/tests/test_security.py index dcbcb890..5efc2b60 100644 --- a/server/tests/test_security.py +++ b/server/tests/test_security.py @@ -122,9 +122,14 @@ def test_security_auxiliary_uploads_content_validated(monkeypatch, tmp_path): "file_path": str(valid_pdb), "filename": "struc.pdb", "is_binary": 0, - "result_dir": str(tmp_path / "results"), "uploaded_at": time.time(), "username": "tester", + "task_type": "rfdiffusion", + "scope_type": "personal", + "scope_id": "1", + "storage_key": "tester-abcdef", + "submitted_by_user_id": 1, + "artifact_provenance": "[]", } saved_inputs = [ {"blob_path": str(valid_pdb), "relative_path": "struc.pdb"}, diff --git a/server/tests/test_security_hardening.py b/server/tests/test_security_hardening.py index b2d0058f..babe3cd9 100644 --- a/server/tests/test_security_hardening.py +++ b/server/tests/test_security_hardening.py @@ -13,7 +13,6 @@ from __future__ import annotations import hashlib -import os import re import shutil import socket @@ -88,15 +87,9 @@ def test_api_key_validation_with_several_users(tmp_path): assert "ix_users_api_key_digest" in indexes -def test_migration_from_old_api_key_hash_schema(tmp_path): - """A DB created with the old api_key_hash schema gains api_key_digest. - - The old werkzeug KDF hashes are one-way — existing keys become invalid - by design; the column is added, indexed, and the user data survives. - """ +def test_old_api_key_schema_fails_without_mutating_state(tmp_path): path = tmp_path / "legacy.sqlite3" - old_key = "revodesign_" + os.urandom(32).hex() - old_kdf_hash = generate_password_hash(old_key) + old_kdf_hash = generate_password_hash("revodesign_old-key") conn = sqlite3.connect(path) conn.execute( """ @@ -125,35 +118,36 @@ def test_migration_from_old_api_key_hash_schema(tmp_path): registration_ip VARCHAR(45), registration_country VARCHAR(8), token_version INTEGER NOT NULL DEFAULT 0, - allow_gpu_use BOOLEAN NOT NULL DEFAULT 0 + allow_gpu_use BOOLEAN NOT NULL DEFAULT 0, + storage_key VARCHAR(128) NOT NULL UNIQUE ) """ ) conn.execute( - "INSERT INTO users (username, email, password_hash, email_verified, created_at, api_key_hash)" - " VALUES (?, ?, ?, 1, ?, ?)", - ("legacy", "legacy@example.com", generate_password_hash("password123"), time.time(), old_kdf_hash), + "INSERT INTO users (username, email, password_hash, email_verified, created_at, api_key_hash, storage_key)" + " VALUES (?, ?, ?, 1, ?, ?, ?)", + ( + "legacy", + "legacy@example.com", + generate_password_hash("password123"), + time.time(), + old_kdf_hash, + "legacy-abcdef", + ), ) conn.commit() conn.close() - db = UserDatabase(str(path)) # runs the migration - - with db.engine.connect() as c: - cols = {row[1] for row in c.exec_driver_sql("PRAGMA table_info(users)")} - assert "api_key_digest" in cols # column added - assert "api_key_hash" in cols # old column left in place - indexes = {row[1] for row in c.exec_driver_sql("PRAGMA index_list(users)")} - assert "ix_users_api_key_digest" in indexes # migrated column indexed + with pytest.raises(RuntimeError, match="predates the Project Scope schema epoch"): + UserDatabase(str(path)) - user = db.get_user_by_username("legacy") - assert user is not None and user["api_key_digest"] is None - # the old KDF hash was never a digest — the old key is invalid by design - assert db.validate_api_key(old_key) is None - - # the migrated DB is fully functional for new keys - new_key = db.generate_api_key(user["id"]) - assert db.validate_api_key(new_key) is not None + conn = sqlite3.connect(path) + columns = {row[1] for row in conn.execute("PRAGMA table_info(users)")} + row = conn.execute("SELECT username FROM users").fetchone() + conn.close() + assert "api_key_digest" not in columns + assert "api_key_hash" in columns + assert row == ("legacy",) # --------------------------------------------------------------------------- diff --git a/server/tests/test_task_runtime_hardening.py b/server/tests/test_task_runtime_hardening.py index 5a3950e0..ac4078a4 100644 --- a/server/tests/test_task_runtime_hardening.py +++ b/server/tests/test_task_runtime_hardening.py @@ -80,15 +80,23 @@ def test_cleanup_task_workspace_removes_workspace_not_results(rt): ws = rt.CONFIG.workspace_folder res = rt.CONFIG.results_folder md5 = "a" * 32 - (Path(ws) / "alice" / md5 / "inputs").mkdir(parents=True) - (Path(ws) / "alice" / md5 / "inputs" / "query.fasta").write_text(">t\nACDE\n") - result_dir = Path(res) / md5 + task = { + "md5sum": md5, + "scope_type": "personal", + "scope_id": "1", + "storage_key": "alice-abcdef", + } + resolver = rt.StorageResolver(res, ws) + input_root = Path(resolver.get_input_root(task)) + (input_root / "inputs").mkdir(parents=True) + (input_root / "inputs" / "query.fasta").write_text(">t\nACDE\n") + result_dir = Path(resolver.get_task_root(task)) result_dir.mkdir(parents=True) (result_dir / "manifest.json").write_text("{}") - rt._cleanup_task_workspace({"username": "alice", "md5sum": md5}) + rt._cleanup_task_workspace(task) - assert not (Path(ws) / "alice" / md5).exists() + assert not input_root.exists() assert result_dir.exists() assert (result_dir / "manifest.json").exists() diff --git a/server/tests/test_tasks.py b/server/tests/test_tasks.py index 8c155cde..961be92f 100644 --- a/server/tests/test_tasks.py +++ b/server/tests/test_tasks.py @@ -19,7 +19,7 @@ import docker import pytest import requests -from conftest import _extract_md5, _load_pssm_module +from conftest import _extract_md5, _load_pssm_module, _personal_task_scope, _relocate_task_artifacts from werkzeug.utils import secure_filename SERVER_PACKAGE = Path(__file__).resolve().parents[1] / "revocompute" @@ -158,6 +158,10 @@ def test_create_task_supports_task_type_deep_links(): assert 'new URLSearchParams(window.location.search).get("task_type")' in script assert "task.name === requested" in script assert "/compute/create_task?task_type={{ task_type.name | urlencode }}" in detail + assert "unresolvedRequestedScope = true" in script + assert 'input[name="taskScope"]' in script + assert "input.checked = false" in script + assert 'selectedScope ? selectedScope.value : "personal"' not in script def test_maintenance_page_is_standalone_and_on_mission(): @@ -412,7 +416,8 @@ class _DummyAsyncResult: ) assert resp.status_code == 302, resp.get_data(as_text=True)[:300] md5sum = resp.headers["Location"].rstrip("/").rsplit("/", 1)[-1] - manifest_path = Path(module.task_runtime.CONFIG.workspace_folder) / "tester" / md5sum / "inputs" / "task.json" + task = module.task_store.get_task(md5sum) + manifest_path = Path(module.app.config["storage_resolver"].get_input_root(task)) / "inputs" / "task.json" manifest = json.loads(manifest_path.read_text(encoding="utf-8")) assert manifest["params"]["iter"] == 100 assert manifest["files"][0]["relative_path"] == "2KL8.fasta" @@ -446,7 +451,7 @@ class _Queued: task_id = response.headers["Location"].rsplit("/", 1)[-1] task = module.task_store.get_task(task_id) manifest = json.loads( - (Path(module.app.config["WORKSPACE_FOLDER"]) / "tester" / task_id / "inputs" / "task.json").read_text() + (Path(module.app.config["storage_resolver"].get_input_root(task)) / "inputs" / "task.json").read_text() ) input_form = json.loads(task["input_form"]) assert manifest["params"]["model_preset"] == "multimer" @@ -521,8 +526,9 @@ def _insert_pending_task( fasta_path = result_dir / filename fasta_path.write_bytes(content) md5sum = uuid.uuid4().hex + scope = _personal_task_scope(module, "tester") blob_hash = hashlib.sha256(content).hexdigest() - snapshot_root = Path(module.task_runtime.CONFIG.workspace_folder) / "tester" / md5sum / "inputs" + snapshot_root = Path(module.app.config["storage_resolver"].get_input_root({"md5sum": md5sum, **scope})) / "inputs" snapshot_path = snapshot_root / filename snapshot_path.parent.mkdir(parents=True, exist_ok=True) snapshot_path.write_bytes(content) @@ -534,11 +540,11 @@ def _insert_pending_task( "value": filename, "verified_value": filename, "relative_path": filename, - "mounted": f"/mnt/revocompute/tester/inputs/{filename}", + "mounted": f"/mnt/revocompute/{scope['storage_key']}/inputs/{filename}", "hash": blob_hash, "snapshot_path": str(snapshot_path), "snapshot_root": str(snapshot_root), - "workspace_key": "tester", + "workspace_key": scope["storage_key"], } ] # _execute_compute_task verifies the upload file exists at @@ -546,18 +552,20 @@ def _insert_pending_task( upload_file = Path(module.task_runtime.CONFIG.upload_folder) / f"{blob_hash}.upload" upload_file.parent.mkdir(parents=True, exist_ok=True) upload_file.write_bytes(content) + _relocate_task_artifacts(module, md5sum, result_dir, scope) module.task_store.upsert_task( md5sum, filename=filename, file_path=str(fasta_path), - result_dir=str(result_dir), uploaded_at=time.time(), status="pending", is_binary=0, source_ip="127.0.0.1", user_agent="pytest", username="tester", + submitted_by_user_id=int(scope["scope_id"]), input_form=json.dumps({"user": "tester", "submitted_at": "2026-01-01T00:00:00Z", "entities": entities}), + **scope, ) return md5sum @@ -589,7 +597,7 @@ def _raise_docker_error(task_id, tt, runner, entities, output_dir, stage_callbac assert task["error"].startswith("docker:") assert "Permission denied" in task["error"] - result_dir = Path(task["result_dir"]) + result_dir = Path(module.app.config["storage_resolver"].get_task_root(task)) assert result_dir.is_dir() manifest = json.loads((result_dir / "manifest.json").read_text(encoding="utf-8")) assert manifest["schema_version"] == 3 @@ -641,7 +649,7 @@ def _fake_runner(task_id, tt, runner, entities, output_dir, stage_callback=None, assert task["status"] == "finished" assert task["local_user"] == "pytest:staff-1000:20" assert task["run_stage"] == "blast" - result_dir = Path(task["result_dir"]) + result_dir = Path(module.app.config["storage_resolver"].get_task_root(task)) assert result_dir.is_dir() manifest = json.loads((result_dir / "manifest.json").read_text(encoding="utf-8")) names = {item["path"] for item in manifest["artifacts"]} @@ -794,7 +802,9 @@ def test_worker_recovery_polls_reconnected_docker_outside_startup(monkeypatch, t "status": "running", "container_id": "container-1", "task_type": "gremlin", - "result_dir": str(tmp_path / "result"), + "scope_type": "personal", + "scope_id": "1", + "storage_key": "test-user-abcdef", } started_threads = [] poll_calls = [] @@ -882,8 +892,8 @@ class _Queued: form = json.loads(task["input_form"]) files = [entity for entity in form["entities"] if entity["type"] == "file"] assert [entity["relative_path"] for entity in files] == ["structures/model.pdb", "config/settings.json"] - assert files[0]["mounted"] == "/mnt/revocompute/tester/inputs/structures/model.pdb" - assert form["virtual_root"] == "/mnt/revocompute/tester" + assert files[0]["mounted"] == f"/mnt/revocompute/{task['storage_key']}/inputs/structures/model.pdb" + assert form["virtual_root"] == f"/mnt/revocompute/{task['storage_key']}" assert form["resource_policy"]["cpus"] >= 1 assert form["resource_policy"]["memory"] assert form["resource_policy"]["slurm_time"] @@ -891,7 +901,7 @@ class _Queued: snapshot = Path(entity["snapshot_path"]) assert snapshot.is_file() assert snapshot.resolve().is_relative_to(Path(module.app.config["WORKSPACE_FOLDER"]).resolve()) - assert not any(Path(task["result_dir"]).iterdir()) + assert not any(Path(module.app.config["storage_resolver"].get_task_root(task)).iterdir()) def test_optional_archive_keeps_result_tree(monkeypatch, tmp_path): @@ -1232,7 +1242,7 @@ class _Queued: assert submitted.status_code == 302, submitted.get_json() task = module.task_store.get_task(submitted.headers["Location"].rsplit("/", 1)[-1]) manifest = json.loads( - (Path(module.app.config["WORKSPACE_FOLDER"]) / "tester" / task["md5sum"] / "inputs" / "task.json").read_text( + (Path(module.app.config["storage_resolver"].get_input_root(task)) / "inputs" / "task.json").read_text( encoding="utf-8" ) ) @@ -1499,6 +1509,7 @@ def test_cleanup_expired_task_artifacts_only_removes_old_terminal_results(monkey ("running", old_finished_at, "running", False), ) task_artifacts = [] + scope = _personal_task_scope(module, "tester") for status, finished_at, _expected_status, _expired in tasks: md5sum = uuid.uuid4().hex @@ -1507,11 +1518,12 @@ def test_cleanup_expired_task_artifacts_only_removes_old_terminal_results(monkey (result_dir / "result.txt").write_text("result\n", encoding="utf-8") zip_path = Path(module.app.config["RESULTS_FOLDER"]) / f"{md5sum}_results.zip" zip_path.write_bytes(b"archive") + result_dir = _relocate_task_artifacts(module, md5sum, result_dir, scope) + zip_path = Path(module.app.config["storage_resolver"].get_archive_path({"md5sum": md5sum, **scope})) module.task_store.upsert_task( md5sum, filename="input.fasta", file_path=str(result_dir / "input.fasta"), - result_dir=str(result_dir), uploaded_at=finished_at - 60, finished_at=finished_at, status=status, @@ -1519,6 +1531,8 @@ def test_cleanup_expired_task_artifacts_only_removes_old_terminal_results(monkey source_ip="127.0.0.1", user_agent="pytest", username="tester", + submitted_by_user_id=int(scope["scope_id"]), + **scope, ) task_artifacts.append((md5sum, result_dir, zip_path)) @@ -1558,16 +1572,20 @@ def test_cleanup_skips_task_replaced_before_atomic_claim(monkeypatch, tmp_path): result_dir = Path(module.app.config["RESULTS_FOLDER"]) / md5sum result_dir.mkdir(parents=True) fresh_artifact = result_dir / "fresh-result.txt" + scope = _personal_task_scope(module, "tester") + result_dir = _relocate_task_artifacts(module, md5sum, result_dir, scope) + fresh_artifact = result_dir / "fresh-result.txt" module.task_store.upsert_task( md5sum, filename="input.fasta", file_path=str(result_dir / "input.fasta"), - result_dir=str(result_dir), uploaded_at=now - 32 * 86400, finished_at=now - 31 * 86400, status="finished", is_binary=0, username="tester", + submitted_by_user_id=int(scope["scope_id"]), + **scope, ) original_claim = module.task_store.claim_task_cleanup @@ -1700,11 +1718,12 @@ def _upsert_task_for_user( status: str = "finished", run_stage: str | None = None, ) -> None: + scope = _personal_task_scope(module, username) + _relocate_task_artifacts(module, md5sum, result_dir, scope) module.task_store.upsert_task( md5sum, filename=filename, file_path=str(file_path), - result_dir=str(result_dir), uploaded_at=time.time(), started_at=time.time(), finished_at=time.time(), @@ -1714,7 +1733,9 @@ def _upsert_task_for_user( source_ip="127.0.0.1", user_agent="pytest", username=username, + submitted_by_user_id=int(scope["scope_id"]), run_stage=run_stage, + **scope, ) @@ -2504,16 +2525,19 @@ def test_nginx_download_offload_returns_internal_redirect(monkeypatch, tmp_path) response = client.get(f"/compute/api/download/{md5sum}", headers=auth_header) head_response = client.head(f"/compute/api/download/{md5sum}", headers=auth_header) + task = module.task_store.get_task(md5sum) + archive = Path(module.app.config["storage_resolver"].get_archive_path(task)) + internal_archive = archive.relative_to(Path(module.app.config["RESULTS_FOLDER"])).as_posix() assert response.status_code == 200 assert response.data == b"" - assert response.headers["X-Accel-Redirect"] == f"/_protected_results/{archive.name}" + assert response.headers["X-Accel-Redirect"] == f"/_protected_results/{internal_archive}" assert response.headers["Content-Type"] == "application/zip" assert response.headers["Cache-Control"] == "private, no-store" assert response.headers["Content-Disposition"].startswith("attachment;") assert head_response.status_code == 200 assert head_response.data == b"" - assert head_response.headers["X-Accel-Redirect"] == f"/_protected_results/{archive.name}" + assert head_response.headers["X-Accel-Redirect"] == f"/_protected_results/{internal_archive}" def test_download_does_not_pack_missing_archive_in_request(monkeypatch, tmp_path): diff --git a/server/tests/test_workflow_composer.py b/server/tests/test_workflow_composer.py index 547a9102..9f49eed5 100644 --- a/server/tests/test_workflow_composer.py +++ b/server/tests/test_workflow_composer.py @@ -14,6 +14,14 @@ from revocompute.task_types import RunnerConfig, RuntimeFamily, TaskType, WorkflowStage +@pytest.fixture(autouse=True) +def _isolated_runtime_state(monkeypatch, tmp_path): + server_root = Path(__file__).resolve().parents[1] + monkeypatch.setenv("SERVER_DIR", str(tmp_path)) + monkeypatch.setenv("CONFIG_DIR", str(server_root / "config")) + monkeypatch.setenv("ENABLED_TASKRUNNERS", "alphafold") + + def _policy(requires_gpu: bool) -> ResolvedResources: return ResolvedResources( cpus=8, @@ -33,10 +41,6 @@ def _policy(requires_gpu: bool) -> ResolvedResources: def test_composer_resumes_after_completed_feature_stage(monkeypatch): - server_root = Path(__file__).resolve().parents[1] - monkeypatch.setenv("SERVER_DIR", str(server_root)) - monkeypatch.setenv("CONFIG_DIR", str(server_root / "config")) - monkeypatch.setenv("ENABLED_TASKRUNNERS", "alphafold") from revocompute import task_runtime runtime = RuntimeFamily("alphafold", "image", ("bash", "run.sh"), "Dockerfile", "runner.def", "image.sif")