c2f566512a
Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.
- migration 030: memberships.scope_type += 'global' (the global RFC
Contributor tier; sentinel scope_id '*').
- auth.effective_scope_role folds global → project → collection,
most-permissive, no negative override; can_read_collection /
can_contribute_in_collection / is_collection_superuser /
can_create_collection gate reads, writes, admin, and create.
- collection-grain visibility: a gated collection is hidden from the
public (404, omitted from the directory) yet visible+listed for a
scope-role holder; a collection may be set only as strict or stricter
than its project (public < unlisted < gated), validated at create and
clamped at the mirror.
- entry-scoped authority (mark-reviewed, graduate, branch read/contribute,
PR/discussion/contribution moderation) re-pointed from the project grain
to the entry's collection.
- create-collection authority widened to a project/global-scope grant
holder (§B.1), not only a deployment owner/admin.
Keystone reconciliation (session 0076): a plain granted account is a
granted *account*, not a write-everywhere global role; the implicit-public
write baseline is grandfathered onto the migration-seeded `default`
collection only, so the N=1 deployment loses no capability. Reinterprets
§B.1/§B.3 literally — flagged for the SPEC merge (S6).
Completes @S3 (C1.1–C1.8). Tests: test_s3_scope_roles_vertical.py (8 C.1
scenarios + visibility/strictness), test_migration_030_global_scope.py.
Full backend suite 493 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
6.0 KiB
Python
137 lines
6.0 KiB
Python
"""§22 S2 — collection directory + create-collection.
|
|
|
|
GET /api/projects/:id/collections — list the project's visible collections.
|
|
GET /api/projects/:id/collections/:cid — one collection's settings.
|
|
POST /api/projects/:id/collections — create a collection. Authorized by a
|
|
deployment owner/admin (S2; scoped
|
|
{owner, contributor} roles at the
|
|
collection axis land in S3). The bot
|
|
commits a `.collection.yaml` to the
|
|
content repo, then the registry mirror
|
|
upserts the collections row — §22.2
|
|
keeps the registry the source of truth.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any
|
|
|
|
import yaml
|
|
from fastapi import APIRouter, HTTPException, Request
|
|
from pydantic import BaseModel
|
|
|
|
from . import (
|
|
auth,
|
|
collections as collections_mod,
|
|
projects as projects_mod,
|
|
registry as registry_mod,
|
|
)
|
|
from .bot import Bot
|
|
from .config import Config
|
|
from .gitea import Gitea, GiteaError
|
|
|
|
_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
|
|
|
|
|
|
class CreateCollectionBody(BaseModel):
|
|
collection_id: str
|
|
type: str
|
|
name: str | None = None
|
|
visibility: str | None = None
|
|
initial_state: str | None = None
|
|
|
|
|
|
def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
|
|
router = APIRouter()
|
|
|
|
@router.get("/api/projects/{project_id}/collections")
|
|
async def list_cols(project_id: str, request: Request) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
# §22.5 read gate: a gated project 404s a non-member.
|
|
auth.require_project_readable(viewer, project_id)
|
|
# §22.5 (S3): the directory is viewer-aware — a hidden/gated collection
|
|
# is listed only for a scope-role holder who can read it; `unlisted` is
|
|
# omitted from enumeration for everyone (link-only).
|
|
items = [
|
|
c
|
|
for c in collections_mod.list_collections(project_id, include_unlisted=True)
|
|
if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"])
|
|
]
|
|
return {"items": items}
|
|
|
|
@router.get("/api/projects/{project_id}/collections/{collection_id}")
|
|
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]:
|
|
viewer = auth.current_user(request)
|
|
auth.require_project_readable(viewer, project_id)
|
|
col = collections_mod.get_collection(collection_id)
|
|
if col is None or col["project_id"] != project_id:
|
|
raise HTTPException(404, "Not found")
|
|
# §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer.
|
|
auth.require_collection_readable(viewer, collection_id)
|
|
return col
|
|
|
|
@router.post("/api/projects/{project_id}/collections")
|
|
async def create_col(
|
|
project_id: str, body: CreateCollectionBody, request: Request
|
|
) -> dict[str, Any]:
|
|
# §B.1 (S3) authority: a deployment owner/admin or a project/global-scope
|
|
# grant holder (Owner or RFC Contributor) may create a collection. The
|
|
# read gate runs first so a gated project 404s a non-member.
|
|
user = auth.require_contributor(request)
|
|
auth.require_project_readable(user, project_id)
|
|
if not auth.can_create_collection(user, project_id):
|
|
raise HTTPException(403, "You may not create collections in this project")
|
|
cid = body.collection_id.strip().lower()
|
|
if not _SLUG_RE.match(cid) or cid == "default":
|
|
raise HTTPException(422, "collection id must be a slug and not 'default'")
|
|
if body.type not in registry_mod.VALID_TYPES:
|
|
raise HTTPException(422, f"invalid type {body.type!r}")
|
|
if body.visibility is not None:
|
|
if body.visibility not in registry_mod.VALID_VISIBILITY:
|
|
raise HTTPException(422, f"invalid visibility {body.visibility!r}")
|
|
# §22.5 (S3) strictness: a collection may be set only as strict or
|
|
# stricter than its project — never more public.
|
|
pvis = auth.project_visibility(project_id)
|
|
if auth.visibility_rank(body.visibility) < auth.visibility_rank(pvis):
|
|
raise HTTPException(
|
|
422,
|
|
f"collection visibility {body.visibility!r} is looser than "
|
|
f"the project's {pvis!r}; a collection may only narrow it",
|
|
)
|
|
if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE:
|
|
raise HTTPException(422, f"invalid initial_state {body.initial_state!r}")
|
|
if collections_mod.get_collection(cid) is not None:
|
|
raise HTTPException(409, f"collection `{cid}` already exists")
|
|
content_repo = projects_mod.content_repo(project_id)
|
|
if not content_repo:
|
|
raise HTTPException(409, "project has no content repo")
|
|
|
|
manifest: dict[str, Any] = {"type": body.type}
|
|
if body.name:
|
|
manifest["name"] = body.name
|
|
if body.visibility:
|
|
manifest["visibility"] = body.visibility
|
|
if body.initial_state:
|
|
manifest["initial_state"] = body.initial_state
|
|
manifest_yaml = yaml.safe_dump(manifest, sort_keys=False)
|
|
|
|
try:
|
|
await bot.create_collection(
|
|
user.as_actor(),
|
|
org=config.gitea_org,
|
|
content_repo=content_repo,
|
|
collection_id=cid,
|
|
manifest_yaml=manifest_yaml,
|
|
)
|
|
except GiteaError as e:
|
|
raise HTTPException(502, f"Gitea: {e.detail}")
|
|
|
|
# §22.2: re-read the registry so the new manifest becomes a row.
|
|
await registry_mod.refresh_registry(config, gitea)
|
|
col = collections_mod.get_collection(cid)
|
|
if col is None:
|
|
raise HTTPException(500, "collection committed but not mirrored")
|
|
return col
|
|
|
|
return router
|