Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions server/revocompute/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
from flask import Flask, g, jsonify, request
from revocompute.auth import _SECRET_KEY as _TOKEN_SIGNING_KEY # noqa: E402
from revocompute.auth import UserDatabase # noqa: E402
from revocompute.collaboration import CollaborationDatabase # noqa: E402
from revocompute.storage import StorageResolver # noqa: E402
from revocompute.auth import _env_bool # noqa: E402
from revocompute.config import ComputeConfig
from revocompute.config import ensure_directories as _ensure_directories
Expand Down Expand Up @@ -115,6 +117,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.
Expand Down Expand Up @@ -188,6 +191,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.
Expand Down
5 changes: 5 additions & 0 deletions server/revocompute/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import html
import logging
import os
import re
import secrets
import smtplib
import time
Expand Down Expand Up @@ -93,6 +94,7 @@
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=True, unique=True),
)


Expand Down Expand Up @@ -147,6 +149,8 @@ def _initialize(self) -> None:
# 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)")
if "storage_key" not in columns:
conn.exec_driver_sql("ALTER TABLE users ADD COLUMN storage_key VARCHAR(128)")
try:
os.chmod(self.path, 0o600)
except OSError:
Expand Down Expand Up @@ -191,6 +195,7 @@ def create_user(
registration_ip=registration_ip,
registration_country=registration_country,
user_status=user_status,
storage_key=f"{re.sub(r'[^A-Za-z0-9]+', '-', username).strip('-').lower()[:32] or 'user'}-{secrets.token_urlsafe(6).lower().replace('_','-')}",
)
with self.engine.begin() as conn:
result = conn.execute(stmt)
Expand Down
251 changes: 251 additions & 0 deletions server/revocompute/collaboration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
# 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

"""Project collaboration and scope authorization primitives.

This module deliberately owns a separate SQLite database from ``UserDatabase``.
User ids are opaque foreign-key values; account lifecycle remains the concern of
``auth.UserDatabase``.
"""

from __future__ import annotations

import re
import secrets
import time
import unicodedata
from typing import Any

import sqlalchemy as sa
from sqlalchemy import Column, Float, Integer, MetaData, String, Table, Text


PROJECT_VISIBILITIES = frozenset({"private", "internal", "public"})
PROJECT_ROLES = frozenset({"owner", "maintainer", "contributor", "viewer"})
INVITATION_STATUSES = frozenset({"pending", "accepted", "declined", "revoked", "expired"})

# Capabilities are intentionally explicit and centralized. A viewer can read
# but cannot submit or reuse artifacts; contributors can submit and reuse.
ROLE_CAPABILITIES: dict[str, frozenset[str]] = {
"owner": frozenset({
"view_project", "view_tasks", "view_results", "use_artifacts",
"submit_tasks", "cancel_own_tasks", "cancel_project_tasks",
"invite_members", "manage_members", "change_project_settings",
"delete_project", "transfer_ownership",
}),
"maintainer": frozenset({
"view_project", "view_tasks", "view_results", "use_artifacts",
"submit_tasks", "cancel_own_tasks", "cancel_project_tasks",
"invite_members", "manage_members", "change_project_settings",
}),
"contributor": frozenset({
"view_project", "view_tasks", "view_results", "use_artifacts",
"submit_tasks", "cancel_own_tasks",
}),
"viewer": frozenset({"view_project", "view_tasks", "view_results"}),
}


def _storage_prefix(value: str) -> str:
value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode()
value = re.sub(r"[^a-zA-Z0-9]+", "-", value).strip("-").lower()
return (value or "scope")[:32]


def new_storage_key(display_name: str) -> str:
"""Return a readable, opaque and immutable storage namespace key."""
return f"{_storage_prefix(display_name)}-{secrets.token_urlsafe(6).lower().replace('_', '-').replace('=', '')}"


class CollaborationDatabase:
"""SQLite-backed Projects, memberships and invitations."""

def __init__(self, path: str):
import os

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 = MetaData()
self.projects = Table(
"projects", self.metadata,
Column("id", Integer, primary_key=True), Column("name", String(200), nullable=False),
Column("slug", String(200), nullable=False, unique=True), Column("description", Text),
Column("visibility", String(16), nullable=False, default="private"),
Column("storage_key", String(128), nullable=False, unique=True),
Column("created_at", Float, nullable=False), Column("updated_at", Float, nullable=False),
Column("archived_at", Float),
)
self.members = Table(
"project_members", self.metadata,
Column("project_id", Integer, primary_key=True), Column("user_id", Integer, primary_key=True),
Column("role", String(16), nullable=False), Column("created_at", Float, nullable=False),
)
self.invitations = Table(
"project_invitations", self.metadata,
Column("id", Integer, primary_key=True), Column("project_id", Integer, nullable=False),
Column("invited_user_id", Integer, nullable=False), Column("invited_by", Integer, nullable=False),
Column("proposed_role", String(16), nullable=False), Column("status", String(16), nullable=False),
Column("created_at", Float, nullable=False), Column("expires_at", Float, nullable=False),
Column("accepted_at", Float),
)
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 journal_mode=WAL")
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 create_project(self, name: str | int, owner_user_id: int | str, *, slug: str | None = None, description: str | None = None, visibility: str = "private") -> dict[str, Any]:
# Accept both ``(name, owner_id)`` and the Flask-facing ``(owner_id, name)``.
if isinstance(name, int) and isinstance(owner_user_id, str):
name, owner_user_id = owner_user_id, name
name = str(name)
owner_user_id = int(owner_user_id)
if visibility not in PROJECT_VISIBILITIES:
raise ValueError("invalid project visibility")
slug = slug or _storage_prefix(name)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make generated project slugs collision-safe

The API does not accept a caller-provided slug, so every project name is reduced to this deterministic prefix while the database requires slug to be globally unique. Once any user creates demo, another user creating demo (or a normalized equivalent such as Demo!) raises an uncaught IntegrityError and returns HTTP 500; generate a unique suffix or scope uniqueness appropriately and return a controlled conflict response.

Useful? React with 👍 / 👎.

now = time.time()
with self.engine.begin() as conn:
result = conn.execute(sa.insert(self.projects).values(name=name, slug=slug, 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=owner_user_id, role="owner", created_at=now))
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:
row = conn.execute(sa.select(self.projects).where(self.projects.c.id == project_id)).first()
return self._row(row)

def get_project_by_slug(self, slug: str) -> dict[str, Any] | None:
with self.engine.connect() as conn:
row = conn.execute(sa.select(self.projects).where(self.projects.c.slug == slug)).first()
return self._row(row)

def list_projects(self, user_id: int, *, authenticated: bool = True) -> list[dict[str, Any]]:
"""List member projects plus discoverable internal/public projects."""
with self.engine.connect() as conn:
rows = conn.execute(sa.select(self.projects).join(self.members, self.projects.c.id == self.members.c.project_id).where(self.members.c.user_id == user_id)).mappings().all()
if authenticated:
rows += conn.execute(sa.select(self.projects).where(self.projects.c.visibility.in_(["internal", "public"]), self.projects.c.archived_at.is_(None))).mappings().all()
seen=set(); result=[]
for row in rows:
if row["id"] not in seen and row.get("archived_at") is None: seen.add(row["id"]); result.append(dict(row))
return result

def membership(self, project_id: int | str, user_id: int):
return self.get_membership(project_id, user_id)

def rename_project(self, project_id: int, name: str, *, slug: str | None = None) -> None:
if not str(name).strip():
raise ValueError("project name cannot be empty")
values = {"name": name, "updated_at": time.time()}
if slug is not None:
values["slug"] = slug
with self.engine.begin() as conn:
conn.execute(sa.update(self.projects).where(self.projects.c.id == project_id).values(**values))

def archive_project(self, project_id: int) -> None:
with self.engine.begin() as conn:
conn.execute(sa.update(self.projects).where(self.projects.c.id == project_id).values(archived_at=time.time(), updated_at=time.time()))

def update_project(self, project_id: int, **fields: Any) -> None:
"""Update mutable settings while preserving the immutable storage key."""
allowed = {"name", "description", "visibility"}
values = {k: v for k, v in fields.items() if k in allowed}
if "visibility" in values and values["visibility"] not in PROJECT_VISIBILITIES:
raise ValueError("invalid project visibility")
if "name" in values and not str(values["name"]).strip():
raise ValueError("project name cannot be empty")
if not values:
return
values["updated_at"] = time.time()
with self.engine.begin() as conn:
conn.execute(sa.update(self.projects).where(self.projects.c.id == project_id).values(**values))

def list_members(self, project_id: int) -> list[dict[str, Any]]:
with self.engine.connect() as conn:
return [self._row(r) for r in conn.execute(sa.select(self.members).where(self.members.c.project_id == project_id)).all()] # type: ignore[misc]

def get_membership(self, project_id: int, user_id: int) -> dict[str, Any] | None:
with self.engine.connect() as conn:
row = conn.execute(sa.select(self.members).where(self.members.c.project_id == project_id, self.members.c.user_id == user_id)).first()
return self._row(row)

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("invalid member role")
with self.engine.begin() as conn:
return conn.execute(sa.update(self.members).where(self.members.c.project_id == project_id, self.members.c.user_id == user_id).values(role=role)).rowcount == 1

def remove_member(self, project_id: int, user_id: int) -> bool:
"""Remove a non-owner member; the sole owner cannot be removed."""
membership = self.get_membership(project_id, user_id)
if not membership or membership["role"] == "owner":
return False
with self.engine.begin() as conn:
return conn.execute(sa.delete(self.members).where(self.members.c.project_id == project_id, self.members.c.user_id == user_id)).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 = time.time()
with self.engine.begin() as conn:
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=expires_at or now + 7 * 86400))
invitation_id = result.inserted_primary_key[0]
return self.get_invitation(invitation_id) # type: ignore[return-value]

def get_invitation(self, invitation_id: int) -> 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 revoke_invitation(self, invitation_id: int) -> 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, user_id: int, accepted: bool) -> bool:
now = time.time()
with self.engine.begin() as conn:
inv = 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 not inv or inv.expires_at < now:
if inv:
conn.execute(sa.update(self.invitations).where(self.invitations.c.id == invitation_id).values(status="expired"))
return False
status = "accepted" if accepted else "declined"
conn.execute(sa.update(self.invitations).where(self.invitations.c.id == invitation_id).values(status=status, accepted_at=now if accepted else None))
if accepted:
conn.execute(sa.insert(self.members).values(project_id=inv.project_id, user_id=user_id, role=inv.proposed_role, created_at=now).prefix_with("OR REPLACE"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve existing owners when accepting invitations

When an invitation targets an existing member, OR REPLACE overwrites that member's current role with the proposed role. A maintainer can therefore invite the project's owner as a viewer, and if the owner accepts, the only owner row is replaced with a viewer row, leaving nobody able to transfer ownership, manage settings, or delete the project; reject invitations for existing members or update them without permitting owner replacement.

Useful? React with 👍 / 👎.

return True

def can(self, project_id: int, user_id: int | None, capability: str, *, authenticated: bool = True) -> bool:
project = self.get_project(project_id)
if not project or project["archived_at"] is not None:
return False
if project["visibility"] == "public" and capability in {"view_project", "view_tasks", "view_results"}:
return True
if project["visibility"] == "internal" and authenticated and capability in {"view_project", "view_tasks", "view_results"}:
return True
if user_id is None:
return False
membership = self.get_membership(project_id, user_id)
return bool(membership and capability in ROLE_CAPABILITIES.get(membership["role"], frozenset()))

can_view_project = lambda self, project_id, user_id=None, **kw: self.can(project_id, user_id, "view_project", **kw)
can_submit_task = lambda self, project_id, user_id: self.can(project_id, user_id, "submit_tasks")
can_use_artifact = lambda self, project_id, user_id: self.can(project_id, user_id, "use_artifacts")
can_manage_members = lambda self, project_id, user_id: self.can(project_id, user_id, "manage_members")


ProjectDatabase = CollaborationDatabase
# Name used by the Flask application wiring.
CollaborationStore = CollaborationDatabase
8 changes: 6 additions & 2 deletions server/revocompute/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,10 @@ def __init__(self, path: str):
Column("slurm_job_id", String),
Column("container_id", String),
Column("workflow_state", Text),
Column("scope_type", String, nullable=False, default="personal"),
Column("scope_id", String),
Column("storage_key", String),
Column("artifact_provenance", Text),
)
Index("idx_tasks_uploaded_at", self.tasks_table.c.uploaded_at)
self._initialize()
Expand All @@ -107,9 +111,9 @@ def _initialize(self) -> None:
# 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"):
for column, ddl in (("container_id", "VARCHAR"), ("workflow_state", "VARCHAR"), ("scope_type", "VARCHAR NOT NULL DEFAULT 'personal'"), ("scope_id", "VARCHAR"), ("storage_key", "VARCHAR"), ("artifact_provenance", "TEXT")):
if column not in existing:
conn.exec_driver_sql(f"ALTER TABLE tasks ADD COLUMN {column} VARCHAR")
conn.exec_driver_sql(f"ALTER TABLE tasks ADD COLUMN {column} {ddl}")

@staticmethod
def _safe_apply_pragmas(conn) -> None:
Expand Down
Loading
Loading