From c2f566512ab442ca0683a7bf5ad2e9c6d1adb873 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 18:07:51 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20S3:=20scope-role=20enforcement=20+?= =?UTF-8?q?=20collection-grain=20visibility=20(@S3)=20=E2=80=94=20v0.42.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 80 +++++ VERSION | 2 +- backend/app/api.py | 23 +- backend/app/api_branches.py | 36 +- backend/app/api_collections.py | 34 +- backend/app/api_contributions.py | 2 +- backend/app/api_discussion.py | 2 +- backend/app/api_graduation.py | 4 +- backend/app/api_prs.py | 2 +- backend/app/auth.py | 316 +++++++++++++++--- backend/app/registry.py | 21 +- backend/migrations/030_global_scope.sql | 34 ++ backend/tests/test_collection_scoped_serve.py | 5 + .../tests/test_migration_030_global_scope.py | 91 +++++ backend/tests/test_project_scoped_propose.py | 7 + backend/tests/test_s3_scope_roles_vertical.py | 287 ++++++++++++++++ ...6-06-05-three-tier-projects-collections.md | 26 +- frontend/package.json | 2 +- 18 files changed, 877 insertions(+), 97 deletions(-) create mode 100644 backend/migrations/030_global_scope.sql create mode 100644 backend/tests/test_migration_030_global_scope.py create mode 100644 backend/tests/test_s3_scope_roles_vertical.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8eaf4f6..b56ae4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,86 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.42.0 — 2026-06-05 + +**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3: +*scope-role enforcement + collection-grain visibility.* Authorization is now +resolved by the four-layer most-permissive union of §B.2 — global → project → +collection → per-entry — over the unified `{owner, contributor}` roles, with the +§22.5 visibility gate enforced at the **collection** grain. A collection can be +"hidden from public existence" (`gated`): invisible to anonymous viewers and +omitted from the project directory, yet readable and listed for any contributor +holding a scope role that reaches it (collection / project / global). A +collection's visibility may be set only as strict or stricter than its project's. +Completes acceptance scenarios `@S3` (C1.1–C1.8: role usage, inheritance, the +most-permissive union, and no-negative-override).** + +See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md) +(Part B roles, Part C.1 scenarios, Part E slice S3). The invitation UI and +role-keyed empty states remain S4; grants in S3 are applied administratively +(via the `memberships` table / an Owner-authorized create surface). + +Added: + +- **Four-layer scope-role resolver** — `effective_scope_role(user, collection)` + folds a deployment owner/admin (global Owner), an explicit global-scope grant, + a project-scope grant, and a collection-scope grant into the most-permissive + unified role over a collection. Owner outranks RFC Contributor; there is no + negative override (a child scope can never subtract a parent grant). +- **Global-scope grants** — migration 030 extends `memberships.scope_type` to + `{global, project, collection}`. A "global RFC Contributor" (writes in every + collection of every project, distinct from a deployment owner) is a + `scope_type='global'` row (sentinel `scope_id='*'`). +- **Collection-grain visibility enforcement** — `can_read_collection` / + `require_collection_readable` gate the collection-scoped read, entry, and + propose routes; the project collection-directory (`GET + /api/projects//collections`) is now viewer-aware (a hidden/gated collection + is listed only for a scope-role holder; `unlisted` stays omitted from + enumeration). The effective read gate is the stricter of the project's and the + collection's visibility. +- **Collection visibility strictness** — a collection may narrow but never widen + its project's visibility (`public` < `unlisted` < `gated`). Enforced at + create-collection (422 on a looser request) and clamped at the registry mirror. +- **create-collection authority widened (§B.1)** — `POST + /api/projects//collections` now admits a project-scope or global-scope + grant holder (Owner **or** RFC Contributor — the project-level "create a + collection" affordance), not only a deployment owner/admin. A collection-scope + grant cannot create sibling collections. + +Changed (breaking): + +- **Write standing now requires an explicit scope grant outside the default + collection.** The pre-three-tier implicit-on-public write baseline (a granted + deployment `contributor` may propose on any public project with no membership + row) is **narrowed to the migration-seeded `default` collection only** (the N=1 + case, §22.13). On every *explicitly-created* collection — and on every project + beyond the default — proposing, discussing, branching, and contributing now + require an explicit `{owner, contributor}` grant at the collection, its + project, or global scope. Reads of public collections are unchanged. +- Entry-scoped authority checks (mark-reviewed, graduate, branch read/contribute, + PR/discussion/contribution moderation) are re-pointed from the project grain to + the entry's **collection** grain, so a collection Owner administers exactly + their collection's subtree and no more. + +Upgrade steps: + +1. The schema migration (`030_global_scope.sql`) runs automatically on deploy and + is backward-data-compatible — existing `memberships` rows are preserved. No + operator action is required for the migration itself. +2. **A single-collection (N=1) deployment needs no further action.** The `default` + collection keeps the implicit-on-public write baseline, so existing granted + contributors keep proposing exactly as before. +3. **A deployment that has created additional collections (S2) MUST grant scope + roles to its contributors.** Any contributor who should write in a non-default + collection (or in a second project) now needs an explicit grant: a + `memberships` row at `scope_type` `collection` (that collection), `project` + (its project — covers every collection within), or `global` (`scope_id='*'` — + every project). A deployment owner/admin is unaffected (global Owner by role). +4. To make a collection **hidden from the public**, set `visibility: gated` in its + `.collection.yaml` (or the project's `visibility` in `projects.yaml`); the + value MUST be as strict or stricter than the parent project's. The mirror + clamps a looser value and logs a warning. + ## 0.41.0 — 2026-06-05 **Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a diff --git a/VERSION b/VERSION index 72a8a63..787ffc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.41.0 +0.42.0 diff --git a/backend/app/api.py b/backend/app/api.py index 55eb9fc..60be3f5 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -821,6 +821,8 @@ def make_router( viewer = auth.current_user(request) auth.require_project_readable(viewer, project_id) _require_collection_in_project(collection_id, project_id) + # §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer. + auth.require_collection_readable(viewer, collection_id) return _list_rfcs_for_collection(collection_id, viewer, unreviewed) @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}") @@ -830,6 +832,7 @@ def make_router( viewer = auth.current_user(request) auth.require_project_readable(viewer, project_id) _require_collection_in_project(collection_id, project_id) + auth.require_collection_readable(viewer, collection_id) return _get_rfc_for_collection(collection_id, slug, viewer) # --------------------------------------------------------------- @@ -839,12 +842,13 @@ def make_router( @router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed") async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]: """§22.4c — clear an active entry's `unreviewed` flag. Authority is the - §22.7 project superuser (project_admin or deployment owner/admin).""" + §B.2 collection Owner (a collection/project/global Owner or deployment + owner/admin reaching the entry's collection).""" viewer = auth.require_user(request) auth.require_project_readable(viewer, project_id) - if not auth.is_project_superuser(viewer, project_id): - raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed") collection_id = collections_mod.default_collection_id(project_id) + if not auth.is_collection_superuser(viewer, collection_id): + raise HTTPException(403, "Only a collection owner can mark an entry reviewed") row = db.conn().execute( "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id), @@ -1004,11 +1008,11 @@ def make_router( async def _propose_into_collection( project_id: str, collection_id: str, payload: ProposeBody, user ) -> dict[str, Any]: - # §22.6/§22.7: proposing a new entry requires project-level contribute - # standing in the *target* project. On the public default project the - # implicit-public baseline preserves the pre-multi-project flow. - if not auth.can_contribute_in_project(user, project_id): - raise HTTPException(403, "You do not have contribute access to this project") + # §B.2 (S3): proposing a new entry requires contribute standing in the + # *target collection* — the four-layer scope-role union, with the + # grandfathered implicit-public baseline on the default collection. + if not auth.can_contribute_in_collection(user, collection_id): + raise HTTPException(403, "You do not have contribute access to this collection") slug = payload.slug.strip().lower() if not entry_mod.is_valid_slug(slug): raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") @@ -1135,6 +1139,9 @@ def make_router( user = auth.require_contributor(request) auth.require_project_readable(user, project_id) _require_collection_in_project(collection_id, project_id) + # §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer + # before the contribute check (existence is not revealed). + auth.require_collection_readable(user, collection_id) return await _propose_into_collection(project_id, collection_id, payload, user) # --------------------------------------------------------------- diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 74b18c8..6d46f4c 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -1264,11 +1264,11 @@ def make_router( return row["on_behalf_of"] if row else None def _can_read_branch(slug: str, branch: str, viewer) -> bool: - # §22.5 visibility gate first (subtractive, §22.7): in a gated project - # nothing — not even main or a read_public branch — is readable by a - # non-member. - pid = auth.project_of_rfc(slug) - if not auth.can_read_project(viewer, pid): + # §22.5 visibility gate first (subtractive, §B.2): in a hidden/gated + # collection nothing — not even main or a read_public branch — is + # readable by a non-scope-role viewer. + cid = auth.collection_of_rfc(slug) + if not auth.can_read_collection(viewer, cid): return False if branch == "main": return True @@ -1277,7 +1277,7 @@ def make_router( return True if viewer is None: return False - if auth.is_project_superuser(viewer, pid): + if auth.is_collection_superuser(viewer, cid): return True creator = _branch_creator(slug, branch) if creator and viewer.gitea_login == creator: @@ -1310,12 +1310,12 @@ def make_router( # legacy `repo:` is set (nothing, after the RFC-0001 fold-back). if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch): return False - pid = auth.project_of_rfc(slug) + cid = auth.collection_of_rfc(slug) # §22.5 visibility gate (subtractive): no contribute in an unreadable - # project. - if not auth.can_read_project(viewer, pid): + # collection. + if not auth.can_read_collection(viewer, cid): return False - if auth.is_project_superuser(viewer, pid): + if auth.is_collection_superuser(viewer, cid): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1326,10 +1326,10 @@ def make_router( return True vis = _branch_vis(slug, branch) if vis["contribute_mode"] == "any-contributor": - # "any contributor" means anyone with project-level write standing - # (§22.6/§22.7) — the implicit-public baseline on a public project, - # or an explicit project_contributor/admin elsewhere. - return auth.can_contribute_in_project(viewer, pid) + # "any contributor" means anyone with collection-level write standing + # (§B.2) — the grandfathered baseline on the public default + # collection, or an explicit scope grant reaching the collection. + return auth.can_contribute_in_collection(viewer, cid) if vis["contribute_mode"] == "specific": row = db.conn().execute( """ @@ -1352,7 +1352,7 @@ def make_router( def _require_branch_owner(rfc, viewer, creator: str | None) -> None: # §22.6: a project_admin is the per-RFC owner/arbiter authority lifted # to project scope, so it (and a deployment owner/admin) clears here. - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1368,7 +1368,7 @@ def make_router( has no owners, so the set collapses to the superuser tier only — sensible because admin oversight is the only path to canonicalizing edits on an unclaimed entry.""" - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1381,7 +1381,7 @@ def make_router( "can_read": _can_read_branch(slug, branch, viewer), "can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False, "can_change_branch_settings": viewer is not None and ( - auth.is_project_superuser(viewer, rfc["project_id"]) + auth.is_collection_superuser(viewer, rfc["collection_id"]) or (creator is not None and viewer.gitea_login == creator) or viewer.gitea_login in (owners + arbiters) ), @@ -1427,7 +1427,7 @@ def make_router( def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool: if viewer is None: return False - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_collections.py b/backend/app/api_collections.py index c80866a..2f990d4 100644 --- a/backend/app/api_collections.py +++ b/backend/app/api_collections.py @@ -49,7 +49,15 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: viewer = auth.current_user(request) # §22.5 read gate: a gated project 404s a non-member. auth.require_project_readable(viewer, project_id) - return {"items": collections_mod.list_collections(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]: @@ -58,22 +66,38 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: 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]: - # S2 authority: deployment owner/admin (the scoped-role surface is S3). - user = auth.require_admin(request) + # §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 and body.visibility not in registry_mod.VALID_VISIBILITY: - raise HTTPException(422, f"invalid visibility {body.visibility!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: diff --git a/backend/app/api_contributions.py b/backend/app/api_contributions.py index e6f36c0..bf6ff4b 100644 --- a/backend/app/api_contributions.py +++ b/backend/app/api_contributions.py @@ -91,7 +91,7 @@ def _viewer_relationship(viewer, slug: str) -> str | None: """Why this viewer can't *request* to contribute — or None if they can. Owners/admins already have the RFC; existing collaborators are already in. Both get a clear 409 rather than a useless self-request.""" - if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_of_rfc(slug)): + if auth.is_rfc_owner(viewer, slug) or auth.is_collection_superuser(viewer, auth.collection_of_rfc(slug)): return "You already own or administer this RFC." if auth.is_rfc_collaborator(viewer, slug): return "You're already a collaborator on this RFC." diff --git a/backend/app/api_discussion.py b/backend/app/api_discussion.py index be57845..840861c 100644 --- a/backend/app/api_discussion.py +++ b/backend/app/api_discussion.py @@ -312,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int: def _can_resolve(rfc, thread, viewer) -> bool: if viewer is None: return False - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index b620aa3..74a472a 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -218,7 +218,7 @@ def make_router( can_merge = ( viewer is not None and ( - auth.is_project_superuser(viewer, rfc["project_id"]) + auth.is_collection_superuser(viewer, rfc["collection_id"]) or viewer.gitea_login in owners or viewer.gitea_login in arbiters ) @@ -795,7 +795,7 @@ def _can_graduate(rfc, viewer) -> bool: if viewer is None: return False # §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters. - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 69a6678..25ecc17 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -786,7 +786,7 @@ def _can_merge(rfc, viewer) -> bool: """§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.""" if viewer is None: return False - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/auth.py b/backend/app/auth.py index 98a6978..4f529ad 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -16,6 +16,7 @@ from typing import Any import httpx from fastapi import HTTPException, Request +from . import collections as collections_mod from . import db from .bot import Actor from .config import Config @@ -336,26 +337,37 @@ def project_visibility(project_id: str) -> str: return row["visibility"] or "gated" -def project_member_role(user: SessionUser | None, project_id: str) -> str | None: - """The user's *explicit* §22.6 membership role at this project, or None. +def _is_default_project(project_id: str) -> bool: + """True iff `project_id` owns the migration-seeded `default` collection — the + deployment's primary project (§22.13), whatever its configured id. Only there + do M2's role rows (which migrated to collection scope `default`) stand in for + project-level authority.""" + return collections_mod.project_of_collection(collections_mod.DEFAULT_COLLECTION_ID) == project_id - §22 three-tier (S1): M2's `project_members` rows migrated into the unified - `memberships` table at the project's default collection (and the project - tier is freshly grantable). The unified `{owner, contributor}` roles are - mapped back to the legacy `project_admin`/`project_contributor` strings the - S1 project-grain authz still speaks; the four-layer scope resolver lands in - S3. Reads the stored grant only (project OR default-collection scope) — it - does not fold in the deployment tier or the implicit-on-public baseline.""" + +def project_member_role(user: SessionUser | None, project_id: str) -> str | None: + """The user's *project-grain* §22.6 role at this project, or None — the + most-permissive of a **global** grant (inherits down to every project) and a + **project**-scope grant. Mapped back to the legacy + `project_admin`/`project_contributor` strings the project-grain authz speaks. + + Back-compat: on the deployment's *default* project only, M2's rows live at + collection scope `default` (§B.3 migration), so a `default` collection-scope + grant there is read as project-level too. A collection grant on any other + project is NOT project authority — that is the four-layer collection resolver + (`effective_scope_role`). Does not fold in the deployment tier + (`is_project_superuser` adds it) or the implicit-on-public baseline.""" if user is None: return None - from . import collections as collections_mod - cid = collections_mod.default_collection_id(project_id) + clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"] + params: list = [user.user_id, project_id] + if _is_default_project(project_id): + clauses.append("(scope_type = 'collection' AND scope_id = ?)") + params.append(collections_mod.DEFAULT_COLLECTION_ID) row = db.conn().execute( - "SELECT role FROM memberships " - "WHERE user_id = ? AND ((scope_type = 'project' AND scope_id = ?) " - " OR (scope_type = 'collection' AND scope_id = ?)) " + "SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") " "ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1", - (user.user_id, project_id, cid), + params, ).fetchone() if row is None: return None @@ -378,6 +390,20 @@ def project_of_rfc(rfc_slug: str) -> str: return row["project_id"] or DEFAULT_PROJECT_ID +def collection_of_rfc(rfc_slug: str) -> str: + """The collection an RFC belongs to (`cached_rfcs.collection_id`). Falls back + to the default collection when the slug isn't cached. Mirrors + `project_of_rfc`'s first-match semantics; a slug shared across collections is + a known routing ambiguity (the RFC-grain helpers take a bare slug) resolved + by the collection-qualified routes in later slices.""" + row = db.conn().execute( + "SELECT collection_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,) + ).fetchone() + if row is None or not row["collection_id"]: + return collections_mod.DEFAULT_COLLECTION_ID + return row["collection_id"] + + def is_project_superuser(user: SessionUser | None, project_id: str) -> bool: """Maximal authority within a project: a deployment owner/admin (superuser in every project, §22.7) or an explicit `project_admin` (§22.6). Both @@ -390,20 +416,30 @@ def is_project_superuser(user: SessionUser | None, project_id: str) -> bool: def can_read_project(user: SessionUser | None, project_id: str) -> bool: - """The §22.5 visibility gate. `public`/`unlisted` are readable by anyone - (anonymous included — `unlisted` is link-only but the link still reads); - `gated` is readable only by a deployment owner/admin or a granted project - member of any role. Used as the subtractive read gate (a gated project's - entries 404 to non-members).""" + """The §22.5 visibility gate at the project grain. `public`/`unlisted` are + readable by anyone (anonymous included — `unlisted` is link-only but the link + still reads); `gated` is readable only by a deployment owner/admin or a + holder of any scope grant reaching the project — a global grant, a project + grant, or membership at *any* collection within it (seeing a collection + implies seeing its project). Used as the subtractive read gate (a gated + project's entries 404 to non-members).""" vis = project_visibility(project_id) if vis in ("public", "unlisted"): return True - # gated — members + superusers only, subject to the §6 admission floor. + # gated — scope-role holders + superusers only, subject to the §6 floor. if user is None or user.permission_state != "granted": return False if user.role in _DEPLOYMENT_SUPERUSER_ROLES: return True - return project_member_role(user, project_id) is not None + row = db.conn().execute( + "SELECT 1 FROM memberships m WHERE m.user_id = ? AND (" + " m.scope_type = 'global'" + " OR (m.scope_type = 'project' AND m.scope_id = ?)" + " OR (m.scope_type = 'collection' AND m.scope_id IN " + " (SELECT id FROM collections WHERE project_id = ?))) LIMIT 1", + (user.user_id, project_id, project_id), + ).fetchone() + return row is not None def require_project_readable(user: SessionUser | None, project_id: str) -> None: @@ -465,6 +501,187 @@ def visible_project_ids(user: SessionUser | None) -> list[str]: return [r["id"] for r in rows if can_read_project(user, r["id"])] +# =========================================================================== +# §22 three-tier — S3. The four-layer scope-role resolver (§B.2) and the +# collection-grain visibility gate. +# +# A grant attaches the unified role {owner, contributor} at a scope: global, +# project, or collection (§B.1). Grants inherit downward, are additive, and +# admit no negative override (§B.2). Effective authority over a *collection* is +# the most-permissive union of the layers reaching it: +# +# global (users.role owner/admin ∪ memberships scope_type='global') +# ∪ project (memberships scope_type='project' at the collection's project) +# ∪ collection (memberships scope_type='collection' at the collection) +# +# minus the §22.5 visibility gate and §6.2 write-mute (subtractive, as today). +# Per-entry authority (owners / arbiters / rfc_collaborators) is a distinct, +# finer layer the RFC-grain helpers union in beneath collection. +# +# OPERATOR DECISIONS (S3, session 0076): +# * scope-role-primary — a plain granted account (users.role 'contributor', no +# membership) is a granted *account*, not a write-everywhere global role. +# Write standing comes from an explicit scope grant; the lone exception is the +# grandfathered implicit-public baseline below. +# * grandfathered baseline — the migration-seeded `default` collection keeps the +# pre-three-tier implicit-on-public write baseline (a granted deployment +# contributor may propose while it is public), so the N=1 deployment (§22.13) +# loses no capability. Every *explicitly-created* collection requires an +# explicit scope grant to write. +# * hidden-from-public — a `gated` collection is invisible to the public (404, +# omitted from the directory) yet visible to any scope-role holder reaching it +# (collection/project/global). A collection's visibility may be set only as +# strict or stricter than its project's (the rank ordering below). +# =========================================================================== + +# The global scope is a single tier per deployment; its grant rows use this +# sentinel scope_id (migration 030). +GLOBAL_SCOPE_ID = "*" + +# §22.5 visibility strictness on the public-exposure axis: `public` is least +# strict, `gated` most strict. A collection may narrow its project's visibility +# but never widen it (`rank(collection) >= rank(project)`). +_VISIBILITY_RANK = {"public": 0, "unlisted": 1, "gated": 2} + + +def visibility_rank(visibility: str | None) -> int: + """The strictness rank of a §22.5 visibility (higher = stricter). An unknown + value reads as the strictest (`gated`) — the safe default.""" + return _VISIBILITY_RANK.get(visibility or "", _VISIBILITY_RANK["gated"]) + + +def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | None: + """The most-permissive unified role ({'owner','contributor'}) the user holds + over `collection_id`, folding §B.2's global → project → collection layers. + Returns None when no scope grant reaches the collection. 'owner' outranks + 'contributor'; there is no negative override (a parent grant is never + subtracted by a child). Subject to the §6 admission floor.""" + if user is None or user.permission_state != "granted": + return None + # Global tier — a deployment owner/admin is a global Owner (§B.1). + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return "owner" + pid = collections_mod.project_of_collection(collection_id) + row = db.conn().execute( + "SELECT role FROM memberships " + "WHERE user_id = ? AND (" + " scope_type = 'global'" + " OR (scope_type = 'project' AND scope_id = ?)" + " OR (scope_type = 'collection' AND scope_id = ?)) " + "ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1", + (user.user_id, pid, collection_id), + ).fetchone() + return row["role"] if row else None + + +def collection_visibility(collection_id: str) -> str: + """The collection's own §22.5 visibility. A missing row reads as 'gated' — + an unknown collection is invisible rather than open.""" + row = db.conn().execute( + "SELECT visibility FROM collections WHERE id = ?", (collection_id,) + ).fetchone() + if row is None or not row["visibility"]: + return "gated" + return row["visibility"] + + +def effective_collection_visibility(collection_id: str) -> str: + """The stricter of the collection's own visibility and its project's (§22.5 + 'both gates'). A collection is constrained to be ≥ its project in strictness, + but we max() defensively so a misconfigured looser collection can never widen + its project's gate.""" + cvis = collection_visibility(collection_id) + pid = collections_mod.project_of_collection(collection_id) + pvis = project_visibility(pid) if pid else "gated" + return cvis if visibility_rank(cvis) >= visibility_rank(pvis) else pvis + + +def can_read_collection(user: SessionUser | None, collection_id: str) -> bool: + """§22.5 read/existence gate at the collection grain. `public`/`unlisted` + read by anyone (anonymous included — `unlisted` is link-only but the link + reads); `gated` ("hidden from public existence") reads only for a scope-role + holder over the collection (collection/project/global) or a deployment + owner/admin. The subtractive read gate — a gated collection 404s a + non-holder, indistinguishable from absent.""" + vis = effective_collection_visibility(collection_id) + if vis in ("public", "unlisted"): + return True + if user is None or user.permission_state != "granted": + return False + return effective_scope_role(user, collection_id) is not None + + +def require_collection_readable(user: SessionUser | None, collection_id: str) -> None: + """Raise 404 when the collection is not readable by this viewer (§22.5: a + hidden/gated collection is invisible to non-holders — the shape matches an + unknown collection).""" + if not can_read_collection(user, collection_id): + raise HTTPException(status_code=404, detail="Not found") + + +def is_collection_superuser(user: SessionUser | None, collection_id: str) -> bool: + """Maximal authority over a collection: an effective scope role of 'owner' + reaching it (a collection Owner, a project Owner of its project, a global + Owner, or a deployment owner/admin). Subsumes the per-entry owners/arbiters + tier within the collection.""" + return effective_scope_role(user, collection_id) == "owner" + + +def _has_collection_write_baseline(user: SessionUser | None, collection_id: str) -> bool: + """The grandfathered implicit-on-public write baseline, narrowed to the + migration-seeded `default` collection (§22.13 N=1 case). A granted deployment + `contributor` keeps its pre-three-tier write standing on the default + collection while its effective visibility is public; every explicitly-created + collection requires an explicit scope grant (S3 operator decision).""" + if user is None or user.permission_state != "granted": + return False + if collection_id != collections_mod.DEFAULT_COLLECTION_ID: + return False + return user.role == "contributor" and effective_collection_visibility(collection_id) == "public" + + +def can_contribute_in_collection(user: SessionUser | None, collection_id: str) -> bool: + """May the user contribute *new* content to the collection (propose an entry) + — the collection-level contribute standing. The union of the scope-role grant + (owner/contributor reaching the collection) and the grandfathered default + baseline, subject to the visibility read gate.""" + if user is None or user.permission_state != "granted": + return False + if not can_read_collection(user, collection_id): + return False + if effective_scope_role(user, collection_id) is not None: + return True + return _has_collection_write_baseline(user, collection_id) + + +def can_discuss_in_collection(user: SessionUser | None, collection_id: str) -> bool: + """May the user participate in discussion in the collection — the + collection-level discuss standing. A superset of contribute for this pass + (the read-only viewer tier is deferred, §B.3), so it mirrors + `can_contribute_in_collection`.""" + return can_contribute_in_collection(user, collection_id) + + +def can_create_collection(user: SessionUser | None, project_id: str) -> bool: + """May the user create a new collection in this project (§B.1)? Creating a + collection is a *project-level* action: a deployment owner/admin, or any + holder of a project-scope or global-scope grant (Owner OR RFC Contributor — + 'anyone at the project level with permission to create a collection'). A + *collection*-scope grant cannot create sibling collections.""" + if user is None or user.permission_state != "granted": + return False + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return True + row = db.conn().execute( + "SELECT 1 FROM memberships " + "WHERE user_id = ? AND (" + " scope_type = 'global'" + " OR (scope_type = 'project' AND scope_id = ?)) LIMIT 1", + (user.user_id, project_id), + ).fetchone() + return row is not None + + # v0.16.0 (roadmap item #12): per-RFC membership helpers. # # These don't replace `require_contributor` — they layer on top of it for @@ -561,16 +778,14 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - pid = project_of_rfc(rfc_slug) - # §22.5 visibility gate is subtractive (§22.7) — no capability in a project - # the viewer cannot even read. - if not can_read_project(user, pid): + cid = collection_of_rfc(rfc_slug) + # §22.5 visibility gate is subtractive (§22.7) — no capability in a + # collection the viewer cannot even read. + if not can_read_collection(user, cid): return False - # §22.7 union, override grants first — these bypass per-RFC curation - # (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all). - if is_project_superuser(user, pid): - return True - if project_member_role(user, pid) in ("project_viewer", "project_contributor"): + # §B.2 union, scope-role grants first — these bypass per-RFC curation (a + # collection/project/global Owner or RFC Contributor ⊇ discussant). + if effective_scope_role(user, cid) is not None: return True # per-RFC authority (union term). owners = _rfc_owners_set(rfc_slug) @@ -578,11 +793,11 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return True if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None): return True - # implicit-public baseline (curation preserved): a granted deployment - # contributor on a public project may discuss only while the RFC is - # unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the + # grandfathered implicit-public baseline (curation preserved): on the default + # collection a granted deployment contributor may discuss only while the RFC + # is unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the # pre-multi-project v0.16.0 contract. - if not owners and _has_write_baseline(user, pid): + if not owners and _has_collection_write_baseline(user, cid): return True return False @@ -606,14 +821,12 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - pid = project_of_rfc(rfc_slug) - if not can_read_project(user, pid): + cid = collection_of_rfc(rfc_slug) + if not can_read_collection(user, cid): return False - # §22.7 union, override grants first (project_contributor ⊇ - # rfc_collaborators(contributor); project_admin / superuser ⊇ all). - if is_project_superuser(user, pid): - return True - if project_member_role(user, pid) == "project_contributor": + # §B.2 union, scope-role grants first (a collection/project/global RFC + # Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all). + if effective_scope_role(user, cid) is not None: return True # per-RFC authority (union term). A 'discussant' row is NOT sufficient — # PRs are the higher-privilege surface. @@ -622,9 +835,10 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return True if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"): return True - # implicit-public baseline (curation preserved): until an owner exists, a - # granted deployment contributor on a public project may contribute. - if not owners and _has_write_baseline(user, pid): + # grandfathered implicit-public baseline (curation preserved): until an owner + # exists, a granted deployment contributor on the public default collection + # may contribute. + if not owners and _has_collection_write_baseline(user, cid): return True return False @@ -637,13 +851,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - pid = project_of_rfc(rfc_slug) - if not can_read_project(user, pid): + cid = collection_of_rfc(rfc_slug) + if not can_read_collection(user, cid): return False - # Deployment owner/admin or project_admin (§22.6) may invite; otherwise - # only the RFC's frontmatter owner. Per-RFC collaborators and the - # implicit-public baseline do not get the invite-others power. - if is_project_superuser(user, pid): + # An Owner reaching the collection (collection/project/global Owner, or a + # deployment owner/admin) may invite; otherwise only the RFC's frontmatter + # owner. Per-RFC collaborators and the baseline do not get the invite power. + if is_collection_superuser(user, cid): return True return is_rfc_owner(user, rfc_slug) diff --git a/backend/app/registry.py b/backend/app/registry.py index b86a7fb..b4e9826 100644 --- a/backend/app/registry.py +++ b/backend/app/registry.py @@ -231,13 +231,30 @@ def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None ) +def _strictest_visibility(a: str, b: str) -> str: + """The stricter of two §22.5 visibilities on the public-exposure axis + (`public` < `unlisted` < `gated`). Used to enforce that a collection is set + only as strict or stricter than its project (S3 operator decision).""" + rank = {"public": 0, "unlisted": 1, "gated": 2} + return a if rank.get(a, 2) >= rank.get(b, 2) else b + + def _upsert_named_collection( proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str ) -> None: """Upsert one named collection (S2). Type is immutable (§22.4a): a type change against an existing row is refused (logged, not applied). A None - manifest visibility inherits the project's visibility.""" - visibility = ce.visibility or proj.visibility + manifest visibility inherits the project's visibility; a manifest that tries + to be *looser* than its project is clamped to the project's (S3 strictness: + a collection may narrow but never widen its project's visibility).""" + requested = ce.visibility or proj.visibility + visibility = _strictest_visibility(requested, proj.visibility) + if visibility != requested: + log.warning( + "registry: collection %s visibility %r looser than project %s %r — " + "clamped to %r (S3 strictness)", + subdir, requested, proj.id, proj.visibility, visibility, + ) with db.tx() as conn: existing = conn.execute( "SELECT type FROM collections WHERE id = ?", (subdir,) diff --git a/backend/migrations/030_global_scope.sql b/backend/migrations/030_global_scope.sql new file mode 100644 index 0000000..b2c0c6d --- /dev/null +++ b/backend/migrations/030_global_scope.sql @@ -0,0 +1,34 @@ +-- migrate:no-foreign-keys +-- +-- §22 three-tier — S3. Admit a *global*-scope grant to the memberships table. +-- +-- §B.2's resolver folds four layers (global → project → collection → per-entry). +-- Migration 029 created `memberships` with scope_type ∈ {project, collection} +-- only; the global tier was left to S3. A global grant is how a "global RFC +-- Contributor" (a contributor who may propose in every collection of every +-- project, distinct from a deployment owner/admin) is represented — see +-- docs/design/2026-06-05-three-tier-projects-collections.md §B.2/§B.3 and the +-- C.1 "cleo" scenario. +-- +-- SQLite can't ALTER a CHECK constraint in place, so the table is rebuilt by the +-- create-copy-drop-rename procedure (the 028/029 pattern). The global scope uses +-- a stable sentinel scope_id of '*' (one global tier per deployment); the +-- UNIQUE(scope_type, scope_id, user_id) then admits exactly one global grant per +-- user, mirroring the project/collection rows. + +CREATE TABLE memberships__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'project', 'collection')), + scope_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')), + granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (scope_type, scope_id, user_id) +); +INSERT INTO memberships__new (id, scope_type, scope_id, user_id, role, granted_by, granted_at) +SELECT id, scope_type, scope_id, user_id, role, granted_by, granted_at FROM memberships; +DROP TABLE memberships; +ALTER TABLE memberships__new RENAME TO memberships; +CREATE INDEX idx_memberships_user ON memberships(user_id); +CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id); diff --git a/backend/tests/test_collection_scoped_serve.py b/backend/tests/test_collection_scoped_serve.py index 7c4d5d2..443cad8 100644 --- a/backend/tests/test_collection_scoped_serve.py +++ b/backend/tests/test_collection_scoped_serve.py @@ -122,6 +122,11 @@ def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea): with TestClient(app) as client: _add_features_collection() provision_user_row(user_id=3, login="alice", role="contributor") + # §22 S3: an explicitly-created collection requires an explicit scope + # grant to write (the grandfathered baseline covers only `default`). + db.conn().execute( + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('collection', 'features', 3, 'contributor')") sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") r = client.post( diff --git a/backend/tests/test_migration_030_global_scope.py b/backend/tests/test_migration_030_global_scope.py new file mode 100644 index 0000000..ec5db73 --- /dev/null +++ b/backend/tests/test_migration_030_global_scope.py @@ -0,0 +1,91 @@ +"""Migration 030 — the global-scope grant (§22 three-tier S3). + +Proves: `memberships.scope_type` now admits 'global' alongside 'project' and +'collection' (§B.2's four-layer resolver), existing rows survive the rebuild, +and the UNIQUE(scope_type, scope_id, user_id) shape is preserved. +Template: test_migration_029_collections.py. +""" +from __future__ import annotations + +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from app import db + + +class _Cfg: + def __init__(self, path): + self.database_path = path + + +def _fresh_db(): + d = tempfile.mkdtemp() + path = Path(d) / "t.db" + db.run_migrations(_Cfg(str(path))) + return db.connect(str(path)) + + +def _add_user(conn, uid, login): + conn.execute( + "INSERT INTO users (id, gitea_id, gitea_login, display_name, role) " + "VALUES (?, ?, ?, ?, 'contributor')", + (uid, uid, login, login.capitalize()), + ) + + +def test_global_scope_type_is_accepted(): + conn = _fresh_db() + _add_user(conn, 1, "cleo") + # global grant — the new tier — is accepted. + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('global', '*', 1, 'contributor')" + ) + row = conn.execute( + "SELECT scope_type, scope_id, role FROM memberships WHERE user_id = 1" + ).fetchone() + assert row["scope_type"] == "global" + assert row["scope_id"] == "*" + assert row["role"] == "contributor" + + +def test_project_and_collection_scopes_still_accepted(): + conn = _fresh_db() + _add_user(conn, 1, "ben") + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('project', 'default', 1, 'owner')" + ) + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('collection', 'default', 1, 'contributor')" + ) + n = conn.execute("SELECT COUNT(*) AS n FROM memberships WHERE user_id = 1").fetchone()["n"] + assert n == 2 + + +def test_unknown_scope_type_still_rejected(): + conn = _fresh_db() + _add_user(conn, 1, "x") + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('deployment', '*', 1, 'owner')" + ) + + +def test_one_global_grant_per_user(): + conn = _fresh_db() + _add_user(conn, 1, "cleo") + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('global', '*', 1, 'contributor')" + ) + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('global', '*', 1, 'owner')" + ) diff --git a/backend/tests/test_project_scoped_propose.py b/backend/tests/test_project_scoped_propose.py index 7cabaf7..e6aa679 100644 --- a/backend/tests/test_project_scoped_propose.py +++ b/backend/tests/test_project_scoped_propose.py @@ -28,6 +28,13 @@ def test_propose_into_second_project_lands_scoped(app_with_fake_gitea): with TestClient(app) as client: _register_ecomm(fake) provision_user_row(user_id=3, login="alice", role="contributor") + # §22 S3: the grandfathered implicit-public baseline covers only the N=1 + # `default` collection; a second project requires an explicit scope grant + # to write. Grant alice contributor at the ecomm project. + from app import db + db.conn().execute( + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('project', 'ecomm', 3, 'contributor')") sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") r = client.post("/api/projects/ecomm/rfcs/propose", json={ diff --git a/backend/tests/test_s3_scope_roles_vertical.py b/backend/tests/test_s3_scope_roles_vertical.py new file mode 100644 index 0000000..2b6f018 --- /dev/null +++ b/backend/tests/test_s3_scope_roles_vertical.py @@ -0,0 +1,287 @@ +"""Slice S3 — scope-role enforcement + collection-grain visibility (@S3). + +The acceptance gate for S3 is "every Part C.1 scenario passes" (the design doc +docs/design/2026-06-05-three-tier-projects-collections.md, §C.1, tagged @S3) plus +the operator's S3 visibility requirements (a collection settable public/hidden; +hidden = visible to project/global scope contributors but not the public; a +collection's visibility may be set only as strict or stricter than its project). + +The §B.2 resolver folds four layers — global → project → collection → per-entry — +most-permissively, with no negative override. The scenarios below are exercised +directly against the resolver/gate helpers, and the visibility ones additionally +through the HTTP surface. + +Background (C.1): a deployment with a project "ohm" owning collections "model" +(document) and "features" (bdd); a second project "acme" with collection +"specs". Plus a hidden ("gated") collection "secret" under ohm for the +hidden-from-public scenarios. +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import ( # noqa: F401 — fixtures land via import + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"): + from app import auth + + return auth.SessionUser( + user_id=user_id, gitea_id=user_id, gitea_login=login, + display_name=login.capitalize(), email=f"{login}@test", avatar_url="", + role=role, permission_state=state, + ) + + +def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) " + "VALUES (?, ?, ?, ?, datetime('now'))", + (pid, pid.capitalize(), content_repo, visibility), + ) + + +def _collection(cid: str, project_id: str, *, ctype: str = "document", + visibility: str = "public", subfolder: str | None = None) -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO collections " + "(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))", + (cid, project_id, ctype, subfolder if subfolder is not None else cid, + visibility, cid.capitalize()), + ) + + +def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES (?, ?, ?, ?)", + (scope_type, scope_id, user_id, role), + ) + + +def _seed_world() -> None: + """The C.1 background plus a hidden collection and a second project.""" + _project("ohm", "public") + _collection("ohm", "ohm", subfolder="") # ohm's structural default + _collection("model", "ohm", ctype="document") + _collection("features", "ohm", ctype="bdd") + _collection("secret", "ohm", visibility="gated") # hidden from public + _project("acme", "public") + _collection("specs", "acme", ctype="specification") + # the cast + for uid, login in [(1, "ada"), (2, "ben"), (3, "cleo"), (4, "dan"), + (5, "eve"), (6, "fay"), (7, "gil"), (8, "hana")]: + provision_user_row(user_id=uid, login=login, role="contributor") + _grant("collection", "model", 1, "contributor") # ada + _grant("project", "ohm", 2, "contributor") # ben + _grant("global", "*", 3, "contributor") # cleo + _grant("collection", "features", 4, "owner") # dan + _grant("project", "ohm", 5, "owner") # eve + _grant("collection", "model", 6, "contributor") # fay (+ project owner below) + _grant("project", "ohm", 6, "owner") # fay + _grant("project", "ohm", 7, "contributor") # gil + # hana (8): no grant. + + +# --------------------------------------------------------------------------- +# C.1 — role usage: inheritance and the most-permissive union +# --------------------------------------------------------------------------- + + +def test_c1_1_collection_contributor_proposes_only_in_that_collection(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + ada = _su(1, "ada") + # may submit a new entry in ohm/model + assert auth.can_contribute_in_collection(ada, "model") is True + # ohm/features is read-only and propose is not offered + assert auth.can_read_collection(ada, "features") is True + assert auth.can_contribute_in_collection(ada, "features") is False + + +def test_c1_2_project_contributor_proposes_in_every_collection(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + ben = _su(2, "ben") + assert auth.can_contribute_in_collection(ben, "model") is True + assert auth.can_contribute_in_collection(ben, "features") is True + # a collection added later is writable with no new grant + _collection("roadmap", "ohm", ctype="document") + assert auth.can_contribute_in_collection(ben, "roadmap") is True + + +def test_c1_3_global_contributor_proposes_everywhere(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + cleo = _su(3, "cleo") + assert auth.can_contribute_in_collection(cleo, "model") is True + assert auth.can_contribute_in_collection(cleo, "specs") is True # acme + + +def test_c1_4_collection_owner_administers_one_collection_only(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + dan = _su(4, "dan") + # graduate / mark-reviewed / manage membership in ohm/features + assert auth.is_collection_superuser(dan, "features") is True + # but not change ohm project settings + assert auth.is_project_superuser(dan, "ohm") is False + assert auth.can_create_collection(dan, "ohm") is False + # and not act on entries in ohm/model + assert auth.is_collection_superuser(dan, "model") is False + assert auth.can_contribute_in_collection(dan, "model") is False + + +def test_c1_5_project_owner_administers_all_collections_and_creates_more(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + eve = _su(5, "eve") + assert auth.is_collection_superuser(eve, "model") is True + assert auth.is_collection_superuser(eve, "features") is True + assert auth.is_project_superuser(eve, "ohm") is True # edit project settings + assert auth.can_create_collection(eve, "ohm") is True # create a new collection + + +def test_c1_6_most_permissive_union_higher_grant_wins(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + fay = _su(6, "fay") + # collection RFC Contributor at model + project Owner at ohm → acts as Owner in model + assert auth.effective_scope_role(fay, "model") == "owner" + assert auth.is_collection_superuser(fay, "model") is True + + +def test_c1_7_no_negative_override(app_with_fake_gitea): + from app import auth, db + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + gil = _su(7, "gil") + # gil can propose in ohm/model via the project grant… + assert auth.can_contribute_in_collection(gil, "model") is True + # …and there is no collection-scope row to remove at model while keeping + # the project grant (a child cannot subtract a parent grant). + row = db.conn().execute( + "SELECT 1 FROM memberships WHERE user_id = 7 AND scope_type = 'collection' AND scope_id = 'model'" + ).fetchone() + assert row is None + + +def test_c1_8_granted_account_no_role_sees_only_public(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + hana = _su(8, "hana") + # may read public collections + assert auth.can_read_collection(hana, "model") is True + # but is not offered the propose action anywhere (no scope role; the + # grandfathered baseline covers only the N=1 `default` collection) + assert auth.can_contribute_in_collection(hana, "model") is False + assert auth.can_contribute_in_collection(hana, "features") is False + assert auth.can_contribute_in_collection(hana, "specs") is False + # gated (hidden) collections do not appear for her + assert auth.can_read_collection(hana, "secret") is False + + +# --------------------------------------------------------------------------- +# Collection-grain visibility — the operator's S3 requirements +# --------------------------------------------------------------------------- + + +def test_hidden_collection_invisible_to_public_visible_to_scope_holder(app_with_fake_gitea): + """A gated collection is omitted from the directory and 404s on read for the + public, yet is listed + readable for a scope-role contributor.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + # anonymous: the gated 'secret' collection is not listed, and 404s. + listed = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} + assert "secret" not in listed + assert "model" in listed # public ones still listed + assert client.get("/api/projects/ohm/collections/secret").status_code == 404 + assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 404 + + # ben (project contributor) sees and reads it. + sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor") + listed2 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} + assert "secret" in listed2 + assert client.get("/api/projects/ohm/collections/secret").status_code == 200 + assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 200 + + # hana (granted, no role) is back to the public view. + sign_in_as(client, user_id=8, gitea_login="hana", display_name="Hana", role="contributor") + listed3 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} + assert "secret" not in listed3 + assert client.get("/api/projects/ohm/collections/secret").status_code == 404 + + +def test_collection_visibility_strictness_validated_at_create(app_with_fake_gitea): + """A collection may be created only as strict or stricter than its project; + a looser request is refused (422). On a gated project, a 'public' collection + is rejected.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _project("locked", "gated") + _collection("locked", "locked", subfolder="", visibility="gated") + # eve is a deployment owner here to clear the create-authority gate; + # the strictness check fires regardless. + provision_user_row(user_id=9, login="root", role="owner") + sign_in_as(client, user_id=9, gitea_login="root", display_name="Root", role="owner") + r = client.post("/api/projects/locked/collections", json={ + "collection_id": "wideopen", "type": "document", "visibility": "public", + }) + assert r.status_code == 422, r.text + assert "looser" in r.json()["detail"] + + +def test_create_collection_allowed_for_project_owner_not_plain_contributor(app_with_fake_gitea): + """§B.1: a project-scope Owner may create a collection; a plain granted + contributor with no project/global grant may not (403).""" + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + # gil is only a project *contributor* on ohm — per §B.1 a project-scope + # contributor CAN create collections (the project-level create + # affordance). A collection-scope grant cannot. + sign_in_as(client, user_id=7, gitea_login="gil", display_name="Gil", role="contributor") + r_ok = client.post("/api/projects/ohm/collections", json={ + "collection_id": "fromgil", "type": "document", "visibility": "public", + }) + assert r_ok.status_code in (200, 502), r_ok.text # past the authz gate + + # ada holds only a *collection*-scope grant (at model) — no create right. + sign_in_as(client, user_id=1, gitea_login="ada", display_name="Ada", role="contributor") + r_no = client.post("/api/projects/ohm/collections", json={ + "collection_id": "fromada", "type": "document", "visibility": "public", + }) + assert r_no.status_code == 403, r_no.text diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md index a152e4c..4592e3f 100644 --- a/docs/design/2026-06-05-three-tier-projects-collections.md +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -559,12 +559,26 @@ means the deployment runs and either gains a capability or provably loses none one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of an empty collection catalog). -- **S3 — Scope-role enforcement.** The four-layer most-permissive resolver - (§B.2) over `{owner, contributor}` grants at `{global, project, collection}`, - with grants applied administratively (DB / admin endpoint); every write gate - re-checked under the collection axis. **Usable end-state:** a user granted - RFC Contributor at a scope can contribute across exactly that subtree, and - Owners administer their subtree. **Completes:** `@S3` (all of C.1 — role usage, +- **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer + most-permissive resolver (§B.2) over `{owner, contributor}` grants at + `{global, project, collection}` (migration 030 adds the `global` scope_type), + with grants applied administratively (DB / the Owner-authorized create + surface); every write gate re-checked under the collection axis. **Plus the + operator's S3 visibility requirements:** collection-grain visibility is + enforced — a `gated` collection is hidden from the public (404, omitted from + the directory) yet visible to scope-role contributors; a collection's + visibility may be set only as strict or stricter than its project's + (`public` < `unlisted` < `gated`). **Keystone reconciliation (session 0076):** + §B.1/§B.3's literal "deployment contributor = global RFC Contributor" + contradicted the C.1 "hana" scenario and the M2 implicit-public baseline; + resolved as — a plain granted account is a granted *account*, not a + write-everywhere global role; "global RFC Contributor" is an explicit + `scope_type='global'` grant; the implicit-public write baseline is + grandfathered onto the migration-seeded `default` collection only (N=1 + preserved). *Flag for the SPEC merge (S6): reinterprets §B.1/§B.3.* **Usable + end-state:** a user granted RFC Contributor at a scope can contribute across + exactly that subtree, Owners administer their subtree, and a collection can be + hidden from the public. **Completes:** `@S3` (all of C.1 — role usage, inheritance, union, no-negative-override). - **S4 — Invitation surfaces + role-aware empty states.** The invite UI diff --git a/frontend/package.json b/frontend/package.json index 3cd1058..1bb033b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.41.0", + "version": "0.42.0", "type": "module", "scripts": { "dev": "vite",