§22 S3: scope-role enforcement + collection-grain visibility — v0.42.0 (@S3) #1

Merged
benstull merged 24 commits from feat/s3-scope-roles-collection-visibility into main 2026-06-06 01:08:55 +00:00
18 changed files with 877 additions and 97 deletions
Showing only changes of commit c2f566512a - Show all commits
+80
View File
@@ -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 release's steps in order — no A-to-B path is pre-computed beyond
that. 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.1C1.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/<id>/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/<id>/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 ## 0.41.0 — 2026-06-05
**Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a **Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a
+1 -1
View File
@@ -1 +1 @@
0.41.0 0.42.0
+15 -8
View File
@@ -821,6 +821,8 @@ def make_router(
viewer = auth.current_user(request) viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id) auth.require_project_readable(viewer, project_id)
_require_collection_in_project(collection_id, 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) return _list_rfcs_for_collection(collection_id, viewer, unreviewed)
@router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}") @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}")
@@ -830,6 +832,7 @@ def make_router(
viewer = auth.current_user(request) viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id) auth.require_project_readable(viewer, project_id)
_require_collection_in_project(collection_id, 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) 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") @router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed")
async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]: 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.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) viewer = auth.require_user(request)
auth.require_project_readable(viewer, project_id) 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) 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( row = db.conn().execute(
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?", "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
(slug, collection_id), (slug, collection_id),
@@ -1004,11 +1008,11 @@ def make_router(
async def _propose_into_collection( async def _propose_into_collection(
project_id: str, collection_id: str, payload: ProposeBody, user project_id: str, collection_id: str, payload: ProposeBody, user
) -> dict[str, Any]: ) -> dict[str, Any]:
# §22.6/§22.7: proposing a new entry requires project-level contribute # §B.2 (S3): proposing a new entry requires contribute standing in the
# standing in the *target* project. On the public default project the # *target collection* — the four-layer scope-role union, with the
# implicit-public baseline preserves the pre-multi-project flow. # grandfathered implicit-public baseline on the default collection.
if not auth.can_contribute_in_project(user, project_id): if not auth.can_contribute_in_collection(user, collection_id):
raise HTTPException(403, "You do not have contribute access to this project") raise HTTPException(403, "You do not have contribute access to this collection")
slug = payload.slug.strip().lower() slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug): if not entry_mod.is_valid_slug(slug):
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
@@ -1135,6 +1139,9 @@ def make_router(
user = auth.require_contributor(request) user = auth.require_contributor(request)
auth.require_project_readable(user, project_id) auth.require_project_readable(user, project_id)
_require_collection_in_project(collection_id, 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) return await _propose_into_collection(project_id, collection_id, payload, user)
# --------------------------------------------------------------- # ---------------------------------------------------------------
+18 -18
View File
@@ -1264,11 +1264,11 @@ def make_router(
return row["on_behalf_of"] if row else None return row["on_behalf_of"] if row else None
def _can_read_branch(slug: str, branch: str, viewer) -> bool: def _can_read_branch(slug: str, branch: str, viewer) -> bool:
# §22.5 visibility gate first (subtractive, §22.7): in a gated project # §22.5 visibility gate first (subtractive, §B.2): in a hidden/gated
# nothing — not even main or a read_public branch — is readable by a # collection nothing — not even main or a read_public branch — is
# non-member. # readable by a non-scope-role viewer.
pid = auth.project_of_rfc(slug) cid = auth.collection_of_rfc(slug)
if not auth.can_read_project(viewer, pid): if not auth.can_read_collection(viewer, cid):
return False return False
if branch == "main": if branch == "main":
return True return True
@@ -1277,7 +1277,7 @@ def make_router(
return True return True
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, pid): if auth.is_collection_superuser(viewer, cid):
return True return True
creator = _branch_creator(slug, branch) creator = _branch_creator(slug, branch)
if creator and viewer.gitea_login == creator: 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). # legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch): if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False return False
pid = auth.project_of_rfc(slug) cid = auth.collection_of_rfc(slug)
# §22.5 visibility gate (subtractive): no contribute in an unreadable # §22.5 visibility gate (subtractive): no contribute in an unreadable
# project. # collection.
if not auth.can_read_project(viewer, pid): if not auth.can_read_collection(viewer, cid):
return False return False
if auth.is_project_superuser(viewer, pid): if auth.is_collection_superuser(viewer, cid):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1326,10 +1326,10 @@ def make_router(
return True return True
vis = _branch_vis(slug, branch) vis = _branch_vis(slug, branch)
if vis["contribute_mode"] == "any-contributor": if vis["contribute_mode"] == "any-contributor":
# "any contributor" means anyone with project-level write standing # "any contributor" means anyone with collection-level write standing
# (§22.6/§22.7) — the implicit-public baseline on a public project, # (§B.2) — the grandfathered baseline on the public default
# or an explicit project_contributor/admin elsewhere. # collection, or an explicit scope grant reaching the collection.
return auth.can_contribute_in_project(viewer, pid) return auth.can_contribute_in_collection(viewer, cid)
if vis["contribute_mode"] == "specific": if vis["contribute_mode"] == "specific":
row = db.conn().execute( row = db.conn().execute(
""" """
@@ -1352,7 +1352,7 @@ def make_router(
def _require_branch_owner(rfc, viewer, creator: str | None) -> None: def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted # §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. # 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 return
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_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 — has no owners, so the set collapses to the superuser tier only —
sensible because admin oversight is the only path to canonicalizing sensible because admin oversight is the only path to canonicalizing
edits on an unclaimed entry.""" 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 return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_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_read": _can_read_branch(slug, branch, viewer),
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False, "can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
"can_change_branch_settings": viewer is not None and ( "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 (creator is not None and viewer.gitea_login == creator)
or viewer.gitea_login in (owners + arbiters) 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: def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool:
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, rfc["project_id"]): if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+29 -5
View File
@@ -49,7 +49,15 @@ def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter:
viewer = auth.current_user(request) viewer = auth.current_user(request)
# §22.5 read gate: a gated project 404s a non-member. # §22.5 read gate: a gated project 404s a non-member.
auth.require_project_readable(viewer, project_id) 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}") @router.get("/api/projects/{project_id}/collections/{collection_id}")
async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]: 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) col = collections_mod.get_collection(collection_id)
if col is None or col["project_id"] != project_id: if col is None or col["project_id"] != project_id:
raise HTTPException(404, "Not found") 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 return col
@router.post("/api/projects/{project_id}/collections") @router.post("/api/projects/{project_id}/collections")
async def create_col( async def create_col(
project_id: str, body: CreateCollectionBody, request: Request project_id: str, body: CreateCollectionBody, request: Request
) -> dict[str, Any]: ) -> dict[str, Any]:
# S2 authority: deployment owner/admin (the scoped-role surface is S3). # §B.1 (S3) authority: a deployment owner/admin or a project/global-scope
user = auth.require_admin(request) # 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) 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() cid = body.collection_id.strip().lower()
if not _SLUG_RE.match(cid) or cid == "default": if not _SLUG_RE.match(cid) or cid == "default":
raise HTTPException(422, "collection id must be a slug and not 'default'") raise HTTPException(422, "collection id must be a slug and not 'default'")
if body.type not in registry_mod.VALID_TYPES: if body.type not in registry_mod.VALID_TYPES:
raise HTTPException(422, f"invalid type {body.type!r}") raise HTTPException(422, f"invalid type {body.type!r}")
if body.visibility is not None and body.visibility not in registry_mod.VALID_VISIBILITY: if body.visibility is not None:
raise HTTPException(422, f"invalid visibility {body.visibility!r}") 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: 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}") raise HTTPException(422, f"invalid initial_state {body.initial_state!r}")
if collections_mod.get_collection(cid) is not None: if collections_mod.get_collection(cid) is not None:
+1 -1
View File
@@ -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. """Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request.""" 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." return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug): if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC." return "You're already a collaborator on this RFC."
+1 -1
View File
@@ -312,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
def _can_resolve(rfc, thread, viewer) -> bool: def _can_resolve(rfc, thread, viewer) -> bool:
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, rfc["project_id"]): if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+2 -2
View File
@@ -218,7 +218,7 @@ def make_router(
can_merge = ( can_merge = (
viewer is not None viewer is not None
and ( 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 owners
or viewer.gitea_login in arbiters or viewer.gitea_login in arbiters
) )
@@ -795,7 +795,7 @@ def _can_graduate(rfc, viewer) -> bool:
if viewer is None: if viewer is None:
return False return False
# §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters. # §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 return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+1 -1
View File
@@ -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.""" """§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
if viewer is None: if viewer is None:
return False return False
if auth.is_project_superuser(viewer, rfc["project_id"]): if auth.is_collection_superuser(viewer, rfc["collection_id"]):
return True return True
owners = json.loads(rfc["owners_json"] or "[]") owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]")
+265 -51
View File
@@ -16,6 +16,7 @@ from typing import Any
import httpx import httpx
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from . import collections as collections_mod
from . import db from . import db
from .bot import Actor from .bot import Actor
from .config import Config from .config import Config
@@ -336,26 +337,37 @@ def project_visibility(project_id: str) -> str:
return row["visibility"] or "gated" return row["visibility"] or "gated"
def project_member_role(user: SessionUser | None, project_id: str) -> str | None: def _is_default_project(project_id: str) -> bool:
"""The user's *explicit* §22.6 membership role at this project, or None. """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 def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
tier is freshly grantable). The unified `{owner, contributor}` roles are """The user's *project-grain* §22.6 role at this project, or None — the
mapped back to the legacy `project_admin`/`project_contributor` strings the most-permissive of a **global** grant (inherits down to every project) and a
S1 project-grain authz still speaks; the four-layer scope resolver lands in **project**-scope grant. Mapped back to the legacy
S3. Reads the stored grant only (project OR default-collection scope) — it `project_admin`/`project_contributor` strings the project-grain authz speaks.
does not fold in the deployment tier or the implicit-on-public baseline."""
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: if user is None:
return None return None
from . import collections as collections_mod clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"]
cid = collections_mod.default_collection_id(project_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( row = db.conn().execute(
"SELECT role FROM memberships " "SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") "
"WHERE user_id = ? AND ((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", "ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
(user.user_id, project_id, cid), params,
).fetchone() ).fetchone()
if row is None: if row is None:
return None return None
@@ -378,6 +390,20 @@ def project_of_rfc(rfc_slug: str) -> str:
return row["project_id"] or DEFAULT_PROJECT_ID 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: def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
"""Maximal authority within a project: a deployment owner/admin (superuser """Maximal authority within a project: a deployment owner/admin (superuser
in every project, §22.7) or an explicit `project_admin` (§22.6). Both 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: def can_read_project(user: SessionUser | None, project_id: str) -> bool:
"""The §22.5 visibility gate. `public`/`unlisted` are readable by anyone """The §22.5 visibility gate at the project grain. `public`/`unlisted` are
(anonymous included — `unlisted` is link-only but the link still reads); readable by anyone (anonymous included — `unlisted` is link-only but the link
`gated` is readable only by a deployment owner/admin or a granted project still reads); `gated` is readable only by a deployment owner/admin or a
member of any role. Used as the subtractive read gate (a gated project's holder of any scope grant reaching the project — a global grant, a project
entries 404 to non-members).""" 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) vis = project_visibility(project_id)
if vis in ("public", "unlisted"): if vis in ("public", "unlisted"):
return True 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": if user is None or user.permission_state != "granted":
return False return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES: if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True 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: 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"])] 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. # v0.16.0 (roadmap item #12): per-RFC membership helpers.
# #
# These don't replace `require_contributor` — they layer on top of it for # 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 return False
if user.permission_state != "granted": if user.permission_state != "granted":
return False return False
pid = project_of_rfc(rfc_slug) cid = collection_of_rfc(rfc_slug)
# §22.5 visibility gate is subtractive (§22.7) — no capability in a project # §22.5 visibility gate is subtractive (§22.7) — no capability in a
# the viewer cannot even read. # collection the viewer cannot even read.
if not can_read_project(user, pid): if not can_read_collection(user, cid):
return False return False
# §22.7 union, override grants first — these bypass per-RFC curation # §B.2 union, scope-role grants first — these bypass per-RFC curation (a
# (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all). # collection/project/global Owner or RFC Contributor ⊇ discussant).
if is_project_superuser(user, pid): if effective_scope_role(user, cid) is not None:
return True
if project_member_role(user, pid) in ("project_viewer", "project_contributor"):
return True return True
# per-RFC authority (union term). # per-RFC authority (union term).
owners = _rfc_owners_set(rfc_slug) owners = _rfc_owners_set(rfc_slug)
@@ -578,11 +793,11 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return True return True
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None): if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
return True return True
# implicit-public baseline (curation preserved): a granted deployment # grandfathered implicit-public baseline (curation preserved): on the default
# contributor on a public project may discuss only while the RFC is # collection a granted deployment contributor may discuss only while the RFC
# unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the # is unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
# pre-multi-project v0.16.0 contract. # 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 True
return False return False
@@ -606,14 +821,12 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False return False
if user.permission_state != "granted": if user.permission_state != "granted":
return False return False
pid = project_of_rfc(rfc_slug) cid = collection_of_rfc(rfc_slug)
if not can_read_project(user, pid): if not can_read_collection(user, cid):
return False return False
# §22.7 union, override grants first (project_contributor ⊇ # §B.2 union, scope-role grants first (a collection/project/global RFC
# rfc_collaborators(contributor); project_admin / superuser ⊇ all). # Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all).
if is_project_superuser(user, pid): if effective_scope_role(user, cid) is not None:
return True
if project_member_role(user, pid) == "project_contributor":
return True return True
# per-RFC authority (union term). A 'discussant' row is NOT sufficient — # per-RFC authority (union term). A 'discussant' row is NOT sufficient —
# PRs are the higher-privilege surface. # PRs are the higher-privilege surface.
@@ -622,9 +835,10 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return True return True
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"): if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
return True return True
# implicit-public baseline (curation preserved): until an owner exists, a # grandfathered implicit-public baseline (curation preserved): until an owner
# granted deployment contributor on a public project may contribute. # exists, a granted deployment contributor on the public default collection
if not owners and _has_write_baseline(user, pid): # may contribute.
if not owners and _has_collection_write_baseline(user, cid):
return True return True
return False return False
@@ -637,13 +851,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False return False
if user.permission_state != "granted": if user.permission_state != "granted":
return False return False
pid = project_of_rfc(rfc_slug) cid = collection_of_rfc(rfc_slug)
if not can_read_project(user, pid): if not can_read_collection(user, cid):
return False return False
# Deployment owner/admin or project_admin (§22.6) may invite; otherwise # An Owner reaching the collection (collection/project/global Owner, or a
# only the RFC's frontmatter owner. Per-RFC collaborators and the # deployment owner/admin) may invite; otherwise only the RFC's frontmatter
# implicit-public baseline do not get the invite-others power. # owner. Per-RFC collaborators and the baseline do not get the invite power.
if is_project_superuser(user, pid): if is_collection_superuser(user, cid):
return True return True
return is_rfc_owner(user, rfc_slug) return is_rfc_owner(user, rfc_slug)
+19 -2
View File
@@ -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( def _upsert_named_collection(
proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str
) -> None: ) -> None:
"""Upsert one named collection (S2). Type is immutable (§22.4a): a type """Upsert one named collection (S2). Type is immutable (§22.4a): a type
change against an existing row is refused (logged, not applied). A None change against an existing row is refused (logged, not applied). A None
manifest visibility inherits the project's visibility.""" manifest visibility inherits the project's visibility; a manifest that tries
visibility = ce.visibility or proj.visibility 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: with db.tx() as conn:
existing = conn.execute( existing = conn.execute(
"SELECT type FROM collections WHERE id = ?", (subdir,) "SELECT type FROM collections WHERE id = ?", (subdir,)
+34
View File
@@ -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);
@@ -122,6 +122,11 @@ def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea):
with TestClient(app) as client: with TestClient(app) as client:
_add_features_collection() _add_features_collection()
provision_user_row(user_id=3, login="alice", role="contributor") 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", sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test") role="contributor", email="alice@test")
r = client.post( r = client.post(
@@ -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')"
)
@@ -28,6 +28,13 @@ def test_propose_into_second_project_lands_scoped(app_with_fake_gitea):
with TestClient(app) as client: with TestClient(app) as client:
_register_ecomm(fake) _register_ecomm(fake)
provision_user_row(user_id=3, login="alice", role="contributor") 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", sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice",
role="contributor", email="alice@test") role="contributor", email="alice@test")
r = client.post("/api/projects/ecomm/rfcs/propose", json={ r = client.post("/api/projects/ecomm/rfcs/propose", json={
@@ -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
@@ -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 one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of
an empty collection catalog). an empty collection catalog).
- **S3 — Scope-role enforcement.** The four-layer most-permissive resolver - **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer
(§B.2) over `{owner, contributor}` grants at `{global, project, collection}`, most-permissive resolver (§B.2) over `{owner, contributor}` grants at
with grants applied administratively (DB / admin endpoint); every write gate `{global, project, collection}` (migration 030 adds the `global` scope_type),
re-checked under the collection axis. **Usable end-state:** a user granted with grants applied administratively (DB / the Owner-authorized create
RFC Contributor at a scope can contribute across exactly that subtree, and surface); every write gate re-checked under the collection axis. **Plus the
Owners administer their subtree. **Completes:** `@S3` (all of C.1 — role usage, 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). inheritance, union, no-negative-override).
- **S4 — Invitation surfaces + role-aware empty states.** The invite UI - **S4 — Invitation surfaces + role-aware empty states.** The invite UI
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "rfc-app-frontend", "name": "rfc-app-frontend",
"private": true, "private": true,
"version": "0.41.0", "version": "0.42.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",