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

Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.

- migration 030: memberships.scope_type += 'global' (the global RFC
  Contributor tier; sentinel scope_id '*').
- auth.effective_scope_role folds global → project → collection,
  most-permissive, no negative override; can_read_collection /
  can_contribute_in_collection / is_collection_superuser /
  can_create_collection gate reads, writes, admin, and create.
- collection-grain visibility: a gated collection is hidden from the
  public (404, omitted from the directory) yet visible+listed for a
  scope-role holder; a collection may be set only as strict or stricter
  than its project (public < unlisted < gated), validated at create and
  clamped at the mirror.
- entry-scoped authority (mark-reviewed, graduate, branch read/contribute,
  PR/discussion/contribution moderation) re-pointed from the project grain
  to the entry's collection.
- create-collection authority widened to a project/global-scope grant
  holder (§B.1), not only a deployment owner/admin.

Keystone reconciliation (session 0076): a plain granted account is a
granted *account*, not a write-everywhere global role; the implicit-public
write baseline is grandfathered onto the migration-seeded `default`
collection only, so the N=1 deployment loses no capability. Reinterprets
§B.1/§B.3 literally — flagged for the SPEC merge (S6).

Completes @S3 (C1.1–C1.8). Tests: test_s3_scope_roles_vertical.py (8 C.1
scenarios + visibility/strictness), test_migration_030_global_scope.py.
Full backend suite 493 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 18:07:51 -07:00
parent 39ce54fbcc
commit c2f566512a
18 changed files with 877 additions and 97 deletions
+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
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
**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)
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)
# ---------------------------------------------------------------
+18 -18
View File
@@ -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 "[]")
+29 -5
View File
@@ -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:
+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.
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."
+1 -1
View File
@@ -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 "[]")
+2 -2
View File
@@ -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 "[]")
+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."""
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 "[]")
+265 -51
View File
@@ -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)
+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(
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,)
+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:
_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(
@@ -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:
_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={
@@ -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
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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.41.0",
"version": "0.42.0",
"type": "module",
"scripts": {
"dev": "vite",