c9fd1c535e
The Owner-only scope-role grant surface (Part E S4 / Part C.2). An Owner
grants {owner, contributor} at a scope their reach covers — the project or
one collection within it — to an existing account looked up by email; the
grant writes a `memberships` row immediately and §15-notifies the grantee
(direct grant, no accept round-trip — the C.2 scenarios name existing
accounts and write the row directly).
- auth.can_invite_at_project / can_invite_at_collection — the Owner-reach
invite gates (is_project_superuser / is_collection_superuser).
- memberships.py — grant (with the C.2.6 broader-supersedes-narrower prune,
preserving a stronger child grant — no negative override), revoke, list,
user_by_email.
- api_memberships.py — GET/POST/DELETE /api/projects/:id/members, the single
POST keying on optional collection_id so the invite UI's one control maps
to one endpoint; reach bounded by the inviter's Owner reach (C.2.3);
contributors refused (C.2.4); a pending grantee's row is recorded but
confers no write (C.2.7, the §6 floor).
- notify.notify_scope_role_granted + render_summary — the §15 personal-direct
notification naming the project and role.
- api_collections — surface viewer capabilities (can_create_collection,
can_invite, can_contribute, role) on the project/collection GETs to drive
the C.3 role-aware empty states.
Acceptance: test_s4_invitations_vertical.py covers C.2.1–C.2.7 over the HTTP
surface + resolver, plus the C.3 (@S4) capability flags. Full suite green
(504 passed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
7.6 KiB
Python
168 lines
7.6 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 _project_viewer_caps(viewer: Any, project_id: str) -> dict[str, Any]:
|
|
"""§22 S4: the viewer's project-grain capabilities for role-aware UI — may
|
|
they create a collection, may they manage membership (invite), and their
|
|
project role. `role` maps the §22.6 legacy strings back to the unified
|
|
`{owner, contributor}` vocabulary the frontend speaks."""
|
|
legacy = auth.project_member_role(viewer, project_id)
|
|
role = None
|
|
if viewer is not None and viewer.role in ("owner", "admin"):
|
|
role = "owner"
|
|
elif legacy == "project_admin":
|
|
role = "owner"
|
|
elif legacy == "project_contributor":
|
|
role = "contributor"
|
|
return {
|
|
"can_create_collection": auth.can_create_collection(viewer, project_id),
|
|
"can_invite": auth.can_invite_at_project(viewer, project_id),
|
|
"role": role,
|
|
}
|
|
|
|
|
|
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"])
|
|
]
|
|
# §22 S4: surface the viewer's project-level capabilities so the
|
|
# directory can render role-aware affordances (the create-first-
|
|
# collection CTA, the invite control) without a second round-trip.
|
|
return {"items": items, "viewer": _project_viewer_caps(viewer, project_id)}
|
|
|
|
@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)
|
|
# §22 S4: the viewer's collection-level capabilities drive the
|
|
# propose-first empty state and the collection invite control.
|
|
col = dict(col)
|
|
col["viewer"] = {
|
|
"can_contribute": auth.can_contribute_in_collection(viewer, collection_id),
|
|
"can_invite": auth.can_invite_at_collection(viewer, collection_id),
|
|
"role": auth.effective_scope_role(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
|