From 503689bf1abc124932c6f984bf33270ce37ad287 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Tue, 2 Jun 2026 20:02:34 -0700 Subject: [PATCH] =?UTF-8?q?feat(projects):=20M2=20=E2=80=94=20project-scop?= =?UTF-8?q?ed=20authorization=20+=20the=20=C2=A722.7=20resolver=20(=C2=A72?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the three-tier authorization resolver on M1's schema spine: the most-permissive union of deployment role (§6.1), project role (§22.6), and per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on top (§22.7). Pure app-layer — no migration (M1 shipped the tables), no behavior change on the public default project. Verifiable on the single default project by flipping its visibility and granting/revoking roles. - app/auth.py: the §22.7 resolver primitives — project_visibility, project_member_role, project_of_rfc, is_project_superuser, can_read_project, effective write/discuss standing (can_contribute_in_project / can_discuss_in_project), require_project_readable, visible_project_ids. The three per-RFC capability helpers (can_discuss_rfc / can_contribute_to_rfc / can_invite_to_rfc) now compose all three tiers, so the ~20 call sites inherit M2 unchanged. - Read pass: the §22.5 visibility gate (404 to non-members) threaded into every RFC-resolution helper across api.py / api_branches / api_prs / api_graduation / api_discussion / api_contributions / api_invitations, plus the catalog + proposals listings filtered by visible_project_ids. - Write pass: the deep gates (branch read/contribute/owner, PR merge/withdraw/ edit, graduation, discussion resolve) fold in project_admin via is_project_superuser; the "any-contributor" branch mode routes through can_contribute_in_project; propose + claim gate on project contribute standing. Deployment-level surfaces (admin idea-PR merge/decline, account notification-mute) stay deployment-scoped. - Operator decisions: implicit-on-public (a granted deployment contributor keeps its pre-M2 write baseline on a public project, no membership row, so the N=1 case stays whole) and preserve-curation (that baseline does not override per-RFC owner curation — only an explicit project grant or a deployment owner/admin does), keeping the v0.16.0 per-RFC invite contract intact on public. - tests: test_multi_project_authz_vertical.py — 9 vertical assertions on the resolver tiers, public-unchanged regression, the gated 404 read gate, the gated contribution gate, union + subtractive visibility, and revocation. Full suite 390 passed. - docs: mark Part C M2 "(landed)" with the two operator decisions. Co-Authored-By: Claude Opus 4.8 --- backend/app/api.py | 41 ++- backend/app/api_branches.py | 98 +++-- backend/app/api_contributions.py | 23 +- backend/app/api_discussion.py | 25 +- backend/app/api_graduation.py | 23 +- backend/app/api_invitations.py | 15 +- backend/app/api_notifications.py | 4 +- backend/app/api_prs.py | 31 +- backend/app/auth.py | 217 ++++++++++- .../test_multi_project_authz_vertical.py | 347 ++++++++++++++++++ docs/design/multi-project-spec.md | 33 +- 11 files changed, 742 insertions(+), 115 deletions(-) create mode 100644 backend/tests/test_multi_project_authz_vertical.py diff --git a/backend/app/api.py b/backend/app/api.py index 0d77109..1b9e51d 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -617,15 +617,23 @@ def make_router( """ viewer = auth.current_user(request) viewer_id = viewer.user_id if viewer else None + # §22.5: a gated project's entries never surface in a non-member's + # catalog. For the single public default project this is the full set. + visible = auth.visible_project_ids(viewer) + if not visible: + return {"items": []} + placeholders = ",".join("?" for _ in visible) rows = db.conn().execute( - """ + f""" SELECT slug, title, state, rfc_id, repo, owners_json, arbiters_json, tags_json, last_main_commit_at, last_entry_commit_at, updated_at FROM cached_rfcs WHERE state IN ('super-draft', 'active') + AND project_id IN ({placeholders}) ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC - """ + """, + visible, ).fetchall() starred = set() @@ -657,12 +665,14 @@ def make_router( return {"items": items} @router.get("/api/rfcs/{slug}") - async def get_rfc(slug: str) -> dict[str, Any]: + async def get_rfc(slug: str, request: Request) -> dict[str, Any]: row = db.conn().execute( "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if row is None: raise HTTPException(404, "Not found") + # §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members. + auth.require_project_readable(auth.current_user(request), row["project_id"]) payload = _serialize_rfc(row) # Roadmap #26: surface the optional propose-time use case on the # RFC view. The idea PR closes on merge, but the canonical row in @@ -694,14 +704,21 @@ def make_router( return row["use_case"] if row else None @router.get("/api/proposals") - async def list_proposals() -> dict[str, Any]: + async def list_proposals(request: Request) -> dict[str, Any]: + # §22.5: idea PRs in a gated project never surface to non-members. + visible = auth.visible_project_ids(auth.current_user(request)) + if not visible: + return {"items": []} + placeholders = ",".join("?" for _ in visible) rows = db.conn().execute( - """ + f""" SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' + AND project_id IN ({placeholders}) ORDER BY opened_at DESC - """ + """, + visible, ).fetchall() return { "items": [ @@ -736,6 +753,8 @@ def make_router( ).fetchone() if row is None: raise HTTPException(404, "Not a proposal PR") + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(auth.current_user(request), row["project_id"]) # Read the proposed entry file from the head branch. slug = row["rfc_slug"] head = row["head_branch"] @@ -771,6 +790,12 @@ def make_router( @router.post("/api/rfcs/propose") async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]: user = auth.require_contributor(request) + # §22.6/§22.7: proposing a new entry requires project-level contribute + # standing. Through M2 every entry lands in the default project; M3's + # routing carries 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, auth.DEFAULT_PROJECT_ID): + raise HTTPException(403, "You do not have contribute access to this project") slug = payload.slug.strip().lower() if not entry_mod.is_valid_slug(slug): raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") @@ -995,10 +1020,12 @@ def make_router( async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]: user = auth.require_contributor(request) rfc = db.conn().execute( - "SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,) + "SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if rfc is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(user, rfc["project_id"]) # §6.7: refuse consent from a user with no registered credentials # — a consent without a universe would be inert and the surface # should fail loudly rather than silently. diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 9218c0b..ebd1e2a 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -120,7 +120,9 @@ def make_router( # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/models") - async def list_models_for_rfc(slug: str) -> dict[str, Any]: + async def list_models_for_rfc(slug: str, request: Request) -> dict[str, Any]: + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + _require_rfc(slug, auth.current_user(request)) resolved = models_resolver.resolve_models_for_rfc(slug, providers) return { "models": [ @@ -140,7 +142,7 @@ def make_router( @router.get("/api/rfcs/{slug}/main") async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_rfc(slug) + rfc = _require_rfc(slug, viewer) if rfc["state"] not in ("active", "super-draft"): raise HTTPException(409, f"RFC is {rfc['state']}") @@ -289,7 +291,7 @@ def make_router( 403, "This RFC's owner has not invited you to contribute PRs", ) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) owner, repo = _repo_for(rfc) new_branch = (body.branch_name or "").strip() if not new_branch: @@ -363,7 +365,7 @@ def make_router( 403, "This RFC's owner has not invited you to contribute PRs", ) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) owner, repo = _repo_for(rfc) new_branch = (body.branch_name or "").strip() if not new_branch: @@ -408,7 +410,7 @@ def make_router( @router.post("/api/rfcs/{slug}/metadata") async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) # Permission: super-draft owners/arbiters per §6.3, plus app-wide # admins/owners per §6.1. Until claim, that collapses to admin/owner. if not _can_edit_metadata(rfc, viewer): @@ -481,7 +483,7 @@ def make_router( request: Request, ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) row = _require_pending_change(slug, branch, change_id) if row["kind"] != "ai": @@ -568,7 +570,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline") async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) row = _require_pending_change(slug, branch, change_id) if row["kind"] != "ai": @@ -586,7 +588,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask") async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) row = _require_change(slug, branch, change_id) if row["kind"] != "ai": @@ -653,7 +655,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush") async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) owner, repo = _repo_for(rfc, branch) path = _file_path_for(rfc, branch) @@ -730,7 +732,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility") async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) creator = _branch_creator(slug, branch) _require_branch_owner(rfc, viewer, creator) current = _branch_vis(slug, branch) @@ -751,7 +753,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/grants") async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) creator = _branch_creator(slug, branch) _require_branch_owner(rfc, viewer, creator) grantee = db.conn().execute( @@ -772,7 +774,7 @@ def make_router( @router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}") async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) creator = _branch_creator(slug, branch) _require_branch_owner(rfc, viewer, creator) grantee = db.conn().execute( @@ -792,7 +794,7 @@ def make_router( @router.get("/api/rfcs/{slug}/branches/{branch:path}/threads") async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") rows = db.conn().execute( @@ -810,7 +812,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/threads") async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if body.thread_kind == "flag" and not body.label: raise HTTPException(422, "Flag threads require a label") cur = db.conn().execute( @@ -840,7 +842,7 @@ def make_router( @router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages") async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") thread = _require_thread(slug, branch, thread_id) @@ -865,7 +867,7 @@ def make_router( slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) _require_thread(slug, branch, thread_id) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") @@ -886,7 +888,7 @@ def make_router( to this (slug, branch) on or before the new cursor is marked read. """ viewer = auth.require_user(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") last_seen = int(body.get("last_seen_message_id") or 0) or None @@ -909,7 +911,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve") async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) thread = _require_thread(slug, branch, thread_id) creator = _branch_creator(slug, branch) if not _can_resolve_thread(rfc, thread, creator, viewer): @@ -932,7 +934,7 @@ def make_router( slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request ): viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) thread = _require_thread(slug, branch, thread_id) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") @@ -1018,7 +1020,7 @@ def make_router( @router.get("/api/rfcs/{slug}/branches/{branch:path}") async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") @@ -1089,31 +1091,34 @@ def make_router( # Permission + state helpers (closures, share `config` etc.) # ------------------------------------------------------------------ - def _require_rfc(slug: str): + def _require_rfc(slug: str, viewer): row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if row is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive, §22.7): a gated project's entries + # 404 to non-members — indistinguishable from an unknown slug. + auth.require_project_readable(viewer, row["project_id"]) return row - def _require_rfc_with_repo(slug: str): + def _require_rfc_with_repo(slug: str, viewer): """Used by every branch-scoped endpoint. Under the meta-only topology (§1) the meta repo is the implicit target for every entry — super-draft and active alike — so there is no per-RFC repo check. The name is retained for call-site stability; a withdrawn entry is still rejected.""" - row = _require_rfc(slug) + row = _require_rfc(slug, viewer) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row - def _require_active_rfc(slug: str): - row = _require_rfc_with_repo(slug) + def _require_active_rfc(slug: str, viewer): + row = _require_rfc_with_repo(slug, viewer) if row["state"] != "active": raise HTTPException(409, f"RFC is {row['state']}, not active") return row - def _require_super_draft(slug: str): - row = _require_rfc(slug) + def _require_super_draft(slug: str, viewer): + row = _require_rfc(slug, viewer) if row["state"] != "super-draft": raise HTTPException(409, f"RFC is {row['state']}, not super-draft") return row @@ -1259,6 +1264,12 @@ 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): + return False if branch == "main": return True vis = _branch_vis(slug, branch) @@ -1266,7 +1277,7 @@ def make_router( return True if viewer is None: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, pid): return True creator = _branch_creator(slug, branch) if creator and viewer.gitea_login == creator: @@ -1299,7 +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 - if viewer.role in ("owner", "admin"): + pid = auth.project_of_rfc(slug) + # §22.5 visibility gate (subtractive): no contribute in an unreadable + # project. + if not auth.can_read_project(viewer, pid): + return False + if auth.is_project_superuser(viewer, pid): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1310,7 +1326,10 @@ def make_router( return True vis = _branch_vis(slug, branch) if vis["contribute_mode"] == "any-contributor": - return True + # "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) if vis["contribute_mode"] == "specific": row = db.conn().execute( """ @@ -1331,7 +1350,9 @@ def make_router( raise HTTPException(403, "You do not have contribute access to this branch") def _require_branch_owner(rfc, viewer, creator: str | None) -> None: - if viewer.role in ("owner", "admin"): + # §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"]): return owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1342,11 +1363,12 @@ def make_router( raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings") def _can_edit_metadata(rfc, viewer) -> bool: - """§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners. - Until §13.1's claim runs, the super-draft has no owners, so the set - collapses to app admins/owners only — sensible because admin oversight - is the only path to canonicalizing edits on an unclaimed entry.""" - if viewer.role in ("owner", "admin"): + """§9.5: super-draft owners/arbiters per §6.3 plus project_admin / + app admins/owners (§22.6). Until §13.1's claim runs, the super-draft + 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"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1359,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 ( - viewer.role in ("owner", "admin") + auth.is_project_superuser(viewer, rfc["project_id"]) or (creator is not None and viewer.gitea_login == creator) or viewer.gitea_login in (owners + arbiters) ), @@ -1405,7 +1427,7 @@ def make_router( def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool: if viewer is None: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_contributions.py b/backend/app/api_contributions.py index e9f4fb8..5a4c0b8 100644 --- a/backend/app/api_contributions.py +++ b/backend/app/api_contributions.py @@ -55,16 +55,19 @@ class ContributionRequestBody(BaseModel): use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX) -def _require_super_draft(slug: str): +def _require_super_draft(slug: str, viewer): """The contribute surface only operates on a *pending* RFC. 404 on unknown; 409 on a state that isn't a super-draft (active RFCs use the - Part-1 link, not a contribute offer; withdrawn is closed).""" + Part-1 link, not a contribute offer; withdrawn is closed). The §22.5 + visibility gate is subtractive: a gated project's entries 404 to + non-members (§22.7).""" row = db.conn().execute( - "SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?", + "SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + auth.require_project_readable(viewer, row["project_id"]) if row["state"] != "super-draft": raise HTTPException(409, "RFC is not a pending super-draft") return row @@ -88,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 viewer.role in ("owner", "admin"): + if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_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." @@ -105,16 +108,18 @@ def make_router() -> APIRouter: @router.get("/api/rfcs/{slug}/contribution-target") async def contribution_target(slug: str, request: Request) -> dict[str, Any]: row = db.conn().execute( - "SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?", + "SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + viewer = auth.current_user(request) + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(viewer, row["project_id"]) from . import rfc_links # local import: avoid a module import cycle owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"]) - viewer = auth.current_user(request) eligible = True reason: str | None = None @@ -159,7 +164,7 @@ def make_router() -> APIRouter: slug: str, body: ContributionRequestBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_super_draft(slug) + _require_super_draft(slug, viewer) reason = _viewer_relationship(viewer, slug) if reason is not None: @@ -214,7 +219,7 @@ def make_router() -> APIRouter: slug: str, request_id: int, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException(403, "Only the RFC's owner can act on contribution requests") @@ -282,7 +287,7 @@ def make_router() -> APIRouter: slug: str, request_id: int, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_super_draft(slug) + _require_super_draft(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException(403, "Only the RFC's owner can act on contribution requests") diff --git a/backend/app/api_discussion.py b/backend/app/api_discussion.py index 8153096..25956b5 100644 --- a/backend/app/api_discussion.py +++ b/backend/app/api_discussion.py @@ -84,7 +84,7 @@ def make_router() -> APIRouter: @router.get("/api/rfcs/{slug}/discussion/threads") async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - _require_rfc_readable(slug) + _require_rfc_readable(slug, viewer) # Ensure the default whole-doc discussion thread exists. We mint # it on first read regardless of viewer (anonymous viewers can # trigger the creation — the row's `created_by` is null in that @@ -115,7 +115,7 @@ def make_router() -> APIRouter: slug: str, body: DiscussionThreadCreateBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_readable(slug) + _require_rfc_readable(slug, viewer) # v0.16.0 (roadmap item #12): the per-RFC discussion is now a # gated surface. The platform-level `require_contributor` above # ensures the user is signed in + admin-granted; this layer @@ -155,8 +155,8 @@ def make_router() -> APIRouter: async def get_discussion_thread_messages( slug: str, thread_id: int, request: Request ) -> dict[str, Any]: - _viewer = auth.current_user(request) - _require_rfc_readable(slug) + viewer = auth.current_user(request) + _require_rfc_readable(slug, viewer) thread = _require_discussion_thread(slug, thread_id) rows = db.conn().execute( """ @@ -193,7 +193,7 @@ def make_router() -> APIRouter: slug: str, thread_id: int, body: DiscussionMessageBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_readable(slug) + _require_rfc_readable(slug, viewer) # v0.16.0 (item #12): same per-RFC gate as create_discussion_thread. if not auth.can_discuss_rfc(viewer, slug): raise HTTPException( @@ -218,7 +218,7 @@ def make_router() -> APIRouter: slug: str, thread_id: int, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_readable(slug) + rfc = _require_rfc_readable(slug, viewer) thread = _require_discussion_thread(slug, thread_id) if not _can_resolve(rfc, thread, viewer): raise HTTPException( @@ -245,15 +245,18 @@ def make_router() -> APIRouter: # --------------------------------------------------------------------------- -def _require_rfc_readable(slug: str): - """Per the v0.3.0 anonymous-read contract: any cached RFC is readable - by anyone. Withdrawn entries refuse reads of every shape — same rule - `_require_rfc_with_repo` in `api_branches.py` follows.""" +def _require_rfc_readable(slug: str, viewer): + """Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable* + project is readable by anyone. The §22.5 visibility gate is subtractive on + top (§22.7): a gated project's entries 404 to non-members. Withdrawn + entries refuse reads of every shape — same rule `_require_rfc_with_repo` + in `api_branches.py` follows.""" row = db.conn().execute( "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + auth.require_project_readable(viewer, row["project_id"]) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row @@ -305,7 +308,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int: def _can_resolve(rfc, thread, viewer) -> bool: if viewer is None: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 6933145..da753f3 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -191,7 +191,7 @@ def make_router( @router.get("/api/rfcs/{slug}/blocking-prs") async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) rows = db.conn().execute( """ SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind @@ -210,7 +210,7 @@ def make_router( can_merge = ( viewer is not None and ( - viewer.role in ("owner", "admin") + auth.is_project_superuser(viewer, rfc["project_id"]) or viewer.gitea_login in owners or viewer.gitea_login in arbiters ) @@ -250,7 +250,7 @@ def make_router( slug: str, request: Request, ) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) del viewer # no permission gate — the dialog only shows up for # admins/owners, but the check itself is read-only. @@ -300,7 +300,7 @@ def make_router( @router.post("/api/rfcs/{slug}/graduate") async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) # §13: only owners/arbiters of the RFC and app admins/owners may # graduate. Until §13.1's claim runs the entry has no owners, so # the set collapses to app admins/owners for unclaimed entries. @@ -441,7 +441,13 @@ def make_router( @router.post("/api/rfcs/{slug}/claim") async def claim_ownership(slug: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) + # §22.6/§22.7: claiming an unclaimed super-draft is a contribute action. + # On a public project this is the pre-M2 baseline (an unclaimed entry + # has no owners, so any granted contributor qualifies); on a gated + # project it requires a project_contributor/admin grant. + if not auth.can_contribute_to_rfc(viewer, slug): + raise HTTPException(403, "You do not have contribute access to this project") existing_owners = json.loads(rfc["owners_json"] or "[]") if viewer.gitea_login in existing_owners: return {"ok": True, "noop": True} @@ -486,10 +492,12 @@ def make_router( # Helpers # ------------------------------------------------------------------- - def _require_super_draft(slug: str): + def _require_super_draft(slug: str, viewer): row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if row is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members. + auth.require_project_readable(viewer, row["project_id"]) if row["state"] != "super-draft": raise HTTPException(409, f"RFC is {row['state']}, not super-draft") return row @@ -666,7 +674,8 @@ async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of def _can_graduate(rfc, viewer) -> bool: if viewer is None: return False - if viewer.role in ("owner", "admin"): + # §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters. + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_invitations.py b/backend/app/api_invitations.py index d6e3d1f..b8440b1 100644 --- a/backend/app/api_invitations.py +++ b/backend/app/api_invitations.py @@ -126,7 +126,7 @@ def make_router() -> APIRouter: @router.post("/api/rfcs/{slug}/invitations") async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc(slug) + rfc = _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, @@ -151,7 +151,7 @@ def make_router() -> APIRouter: @router.get("/api/rfcs/{slug}/invitations") async def list_invitations(slug: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc(slug) + _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, @@ -207,7 +207,7 @@ def make_router() -> APIRouter: @router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke") async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc(slug) + _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, @@ -373,15 +373,18 @@ def make_router() -> APIRouter: # --------------------------------------------------------------------------- -def _require_rfc(slug: str): +def _require_rfc(slug: str, viewer): """The invitation surface only operates on a known, non-withdrawn RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the - discussion endpoints' `_require_rfc_readable` shape.""" + discussion endpoints' `_require_rfc_readable` shape. The §22.5 + visibility gate is subtractive: a gated project's entries 404 to + non-members (§22.7).""" row = db.conn().execute( - "SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,), + "SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + auth.require_project_readable(viewer, row["project_id"]) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row diff --git a/backend/app/api_notifications.py b/backend/app/api_notifications.py index 01f6adc..34320af 100644 --- a/backend/app/api_notifications.py +++ b/backend/app/api_notifications.py @@ -213,9 +213,11 @@ def make_router(config: Config) -> APIRouter: @router.post("/api/rfcs/{slug}/watch") async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]: viewer = auth.require_user(request) - rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() + rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if rfc is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(viewer, rfc["project_id"]) db.conn().execute( """ INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at) diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 69630c8..753675e 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -85,7 +85,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft") async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) owner, repo = _owner_repo(rfc) path = _file_path_for(rfc) if not _branch_has_commits_ahead(slug, branch): @@ -128,7 +128,7 @@ def make_router( 403, "This RFC's owner has not invited you to contribute PRs", ) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) if branch == "main": raise HTTPException(409, "PRs open from non-main branches") owner, repo = _owner_repo(rfc) @@ -207,7 +207,7 @@ def make_router( @router.get("/api/rfcs/{slug}/prs/{pr_number}") async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) owner, repo = _owner_repo(rfc) path = _file_path_for(rfc) @@ -373,7 +373,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/seen") async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_active_rfc(slug) + _require_active_rfc(slug, viewer) _require_pr(slug, pr_number) # Take the max of stored and incoming for both cursors so a # stale tab firing a seen-cursor advance after a fresher tab @@ -420,7 +420,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/review") async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_active_rfc(slug) + _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) head_branch = pr_row["head_branch"] cur = db.conn().execute( @@ -447,7 +447,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/merge") async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if not _can_merge(rfc, viewer): raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge") @@ -479,7 +479,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw") async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if not _can_withdraw(rfc, pr_row, viewer): raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw") @@ -510,7 +510,7 @@ def make_router( slug: str, pr_number: int, body: PRDescriptionBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if not _can_edit_pr_text(rfc, pr_row, viewer): raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit") @@ -535,7 +535,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch") async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if pr_row["state"] != "open": raise HTTPException(409, f"PR is {pr_row['state']}, not open") @@ -661,20 +661,23 @@ def make_router( # Helpers (closures over config/gitea/etc.) # ------------------------------------------------------------------ - def _require_rfc(slug: str): + def _require_rfc(slug: str, viewer): row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if row is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always + # public" yields to a gated project: non-members get 404. + auth.require_project_readable(viewer, row["project_id"]) return row - def _require_active_rfc(slug: str): + def _require_active_rfc(slug: str, viewer): """Used by the §10 PR-flow read and write paths. Per §17's routing- collapse rule, a super-draft RFC also routes here — its body-edit PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API surface is identical. Under the meta-only topology (§1) an active RFC is meta-resident too (repo is null) — that is normal, not an error, so there is no per-RFC-repo precondition.""" - row = _require_rfc(slug) + row = _require_rfc(slug, viewer) if row["state"] not in ("active", "super-draft"): raise HTTPException(409, f"RFC is {row['state']}") return row @@ -780,10 +783,10 @@ def make_router( def _can_merge(rfc, viewer) -> bool: - """§6.1 admin/owner 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: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/auth.py b/backend/app/auth.py index c554ac1..f93438e 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -19,6 +19,7 @@ from fastapi import HTTPException, Request from . import db from .bot import Actor from .config import Config +from .projects import DEFAULT_PROJECT_ID @dataclass @@ -290,6 +291,163 @@ def require_admin(request: Request) -> SessionUser: return user +# =========================================================================== +# §22.6 / §22.7 — project-scoped authorization (the multi-project middle tier). +# +# A deployment hosts N projects (§22). Authorization for an action on an RFC is +# the *most-permissive union* of three tiers — the actor's deployment role +# (§6.1), their project role (§22.6), and their per-RFC authority (§6.3/§12) — +# with the §22.5 visibility gate and the §6.2 write-mute *subtractive* on top +# (§22.7). The per-RFC capability helpers below (`can_discuss_rfc`, +# `can_contribute_to_rfc`, `can_invite_to_rfc`) compose all three tiers, so the +# ~20 endpoint call sites inherit multi-project behavior unchanged. +# +# Through Slice M2 the only project is the migration-seeded `default` one (the +# N=1 case, §22.13); a project is resolved from an RFC slug via +# `cached_rfcs.project_id` (unique per slug while N=1). M3's registry mirror +# lets a deployment declare a second project; these gates already hold then. +# +# OPERATOR DECISIONS (M2): +# * implicit-on-public — on a `public` project a granted deployment +# `contributor` keeps the pre-multi-project write *baseline* (propose +# freely; an owned RFC's discuss/contribute is still gated by the v0.16.0 +# per-RFC invite). No project_members row is needed and no backfill runs, +# so the N=1 case stays whole. Explicit project_members rows and +# gated/unlisted visibility are where the new tier actually bites. +# * preserve curation — the implicit-public baseline does NOT override per-RFC +# owner curation; only an *explicit* project_contributor/project_admin grant +# (or a deployment owner/admin) bypasses it. So §22.7's "project_contributor +# ⊇ rfc_collaborators(contributor)" holds for explicit grants, while a plain +# granted contributor on public behaves exactly as it did before M2. +# =========================================================================== + +_DEPLOYMENT_SUPERUSER_ROLES = ("owner", "admin") + + +def project_visibility(project_id: str) -> str: + """The project's §22.5 visibility ('gated' | 'public' | 'unlisted'). A + missing row reads as 'gated' — the safe default: an unknown project is + invisible rather than open.""" + row = db.conn().execute( + "SELECT visibility FROM projects WHERE id = ?", (project_id,) + ).fetchone() + if row is None: + return "gated" + return row["visibility"] or "gated" + + +def project_member_role(user: SessionUser | None, project_id: str) -> str | None: + """The user's *explicit* §22.6 project_members role in this project, or + None. This is the stored row only — it does not fold in the deployment tier + or the implicit-on-public baseline (those live in the helpers below).""" + if user is None: + return None + row = db.conn().execute( + "SELECT role FROM project_members WHERE project_id = ? AND user_id = ?", + (project_id, user.user_id), + ).fetchone() + return row["role"] if row else None + + +def project_of_rfc(rfc_slug: str) -> str: + """The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to + the default project when the slug isn't cached or the column is unset — the + same N=1 default migration 025 backfills.""" + row = db.conn().execute( + "SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,) + ).fetchone() + if row is None: + return DEFAULT_PROJECT_ID + return row["project_id"] or DEFAULT_PROJECT_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 + subsume the per-RFC owners/arbiters tier.""" + if user is None: + return False + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return True + return project_member_role(user, project_id) == "project_admin" + + +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).""" + vis = project_visibility(project_id) + if vis in ("public", "unlisted"): + return True + # gated — members + superusers only, subject to the §6 admission 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 + + +def require_project_readable(user: SessionUser | None, project_id: str) -> None: + """Raise 404 when the project is not readable by this viewer (§22.5: a + gated project is invisible to non-members — indistinguishable from absent, + so the shape matches an unknown slug).""" + if not can_read_project(user, project_id): + raise HTTPException(status_code=404, detail="RFC not found") + + +def _has_write_baseline(user: SessionUser | None, project_id: str) -> bool: + """The implicit-on-public write baseline: a granted deployment + `contributor` on a `public` project carries the pre-multi-project + write standing (still subject to per-RFC curation). Deployment + owner/admin are handled by `is_project_superuser`; on gated/unlisted a + contributor has no baseline and needs an explicit project role.""" + if user is None or user.permission_state != "granted": + return False + return user.role == "contributor" and project_visibility(project_id) == "public" + + +def can_contribute_in_project(user: SessionUser | None, project_id: str) -> bool: + """May the user contribute *new* content to the project (propose an entry) + — the project-level (not RFC-specific) contribute standing. The union of + the override grants (superuser / explicit project_contributor) and the + implicit-public baseline, subject to the visibility gate.""" + if user is None or user.permission_state != "granted": + return False + if not can_read_project(user, project_id): + return False + if is_project_superuser(user, project_id): + return True + if project_member_role(user, project_id) == "project_contributor": + return True + return _has_write_baseline(user, project_id) + + +def can_discuss_in_project(user: SessionUser | None, project_id: str) -> bool: + """May the user participate in discussion in the project at all — the + project-level discuss standing (project_viewer ⊇ discussant, §22.7). + A superset of `can_contribute_in_project` (a contributor can discuss).""" + if user is None or user.permission_state != "granted": + return False + if not can_read_project(user, project_id): + return False + if is_project_superuser(user, project_id): + return True + if project_member_role(user, project_id) in ("project_viewer", "project_contributor"): + return True + return _has_write_baseline(user, project_id) + + +def visible_project_ids(user: SessionUser | None) -> list[str]: + """Project ids whose entries may surface in a listing for this viewer — the + §22.5 read gate applied to the catalog/idea lists. (The directory's + `unlisted`-omission and the per-project routing are M3 concerns; for the M2 + listing filter we include every project the viewer can read.)""" + rows = db.conn().execute("SELECT id FROM projects").fetchall() + return [r["id"] for r in rows if can_read_project(user, r["id"])] + + # v0.16.0 (roadmap item #12): per-RFC membership helpers. # # These don't replace `require_contributor` — they layer on top of it for @@ -386,18 +544,30 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - if user.role in ("owner", "admin"): + 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): + 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"): + return True + # per-RFC authority (union term). owners = _rfc_owners_set(rfc_slug) - if not owners: - # No owner to gate the invite-list — fall through to the - # platform-granted contract. The first §13.1 claim engages - # the gate; before that, anyone platform-granted can - # contribute (mirrors the v0.5.0 / v0.6.0 contract). - return True if user.gitea_login in owners: return True - return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None) + 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 + # pre-multi-project v0.16.0 contract. + if not owners and _has_write_baseline(user, pid): + return True + return False def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: @@ -419,16 +589,27 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - if user.role in ("owner", "admin"): + pid = project_of_rfc(rfc_slug) + if not can_read_project(user, pid): + 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": + return True + # per-RFC authority (union term). A 'discussant' row is NOT sufficient — + # PRs are the higher-privilege surface. owners = _rfc_owners_set(rfc_slug) - if not owners: - # Same fall-through as can_discuss_rfc: until an owner exists, - # the gate is open. - return True if user.gitea_login in owners: return True - return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor") + 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): + return True + return False def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: @@ -439,7 +620,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - if user.role in ("owner", "admin"): + pid = project_of_rfc(rfc_slug) + if not can_read_project(user, pid): + 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): return True return is_rfc_owner(user, rfc_slug) diff --git a/backend/tests/test_multi_project_authz_vertical.py b/backend/tests/test_multi_project_authz_vertical.py new file mode 100644 index 0000000..c9368a3 --- /dev/null +++ b/backend/tests/test_multi_project_authz_vertical.py @@ -0,0 +1,347 @@ +"""Slice M2 — project-scoped authorization + the §22.7 resolver. + +M1 laid the schema spine (the `projects` / `project_members` tables and the +`project_id` column on every slug-bearing table). M2 builds the resolver on top: +the most-permissive union of the deployment role (§6.1), the project role +(§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate +subtractive on top (§22.7). + +Two operator decisions are pinned here as executable expectations: + + * implicit-on-public — a granted deployment `contributor` keeps its + pre-multi-project write *baseline* on a `public` project (propose freely; + an owned RFC's discuss/contribute still needs the per-RFC invite) with no + project_members row. So the single public default project behaves exactly + as it did before M2 (verified across the rest of the suite, and the + `*_public_*` tests below). + * preserve curation — the implicit-public baseline does NOT override per-RFC + owner curation; only an *explicit* project_contributor/admin grant (or a + deployment owner/admin) bypasses it. + +Everything is exercised on the single `default` project by flipping its +visibility and granting/revoking `project_members` roles — the M2 slice is +verifiable without a second project (which arrives with M3's registry mirror). +""" +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, *, state: str = "granted"): + """A SessionUser handle for direct resolver calls (the endpoint tests use + sign_in_as instead).""" + 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 _set_visibility(project_id: str, visibility: str) -> None: + from app import db + + db.conn().execute( + "UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id) + ) + + +def _add_member(project_id: str, user_id: int, role: str) -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)", + (project_id, user_id, role), + ) + + +def _remove_member(project_id: str, user_id: int) -> None: + from app import db + + db.conn().execute( + "DELETE FROM project_members WHERE project_id = ? AND user_id = ?", + (project_id, user_id), + ) + + +def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None: + """A minimal cached_rfcs row — enough for the authz gates (state, owners, + project_id). project_id defaults to 'default' via migration 025 but we set + it explicitly for clarity.""" + import json + + from app import db + + db.conn().execute( + """ + INSERT OR REPLACE INTO cached_rfcs + (slug, title, state, owners_json, arbiters_json, tags_json, project_id) + VALUES (?, ?, ?, ?, '[]', '[]', ?) + """, + (slug, slug.capitalize(), state, json.dumps(owners or []), project_id), + ) + + +# --------------------------------------------------------------------------- +# 1. The §22.7 resolver — tier composition +# --------------------------------------------------------------------------- + + +def test_resolver_public_project_tiers(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="ben", role="owner") + contributor = _su(1, "alice", "contributor") + owner = _su(2, "ben", "owner") + + # default is public — read open to everyone incl. anonymous. + assert auth.can_read_project(None, "default") is True + assert auth.can_read_project(contributor, "default") is True + + # implicit-on-public: a granted deployment contributor carries the + # write baseline without a project_members row. + assert auth.can_contribute_in_project(contributor, "default") is True + assert auth.can_discuss_in_project(contributor, "default") is True + + # ... but it is NOT project_admin (curation/override authority). + assert auth.is_project_superuser(contributor, "default") is False + # a deployment owner/admin is a superuser in every project. + assert auth.is_project_superuser(owner, "default") is True + + # a pending contributor has no write standing (the §6 admission floor). + pending = _su(1, "alice", "contributor", state="pending") + assert auth.can_contribute_in_project(pending, "default") is False + + +def test_resolver_gated_project_requires_membership(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="ben", role="owner") + _set_visibility("default", "gated") + contributor = _su(1, "alice", "contributor") + owner = _su(2, "ben", "owner") + + # gated: no membership → invisible and no standing. + assert auth.can_read_project(None, "default") is False + assert auth.can_read_project(contributor, "default") is False + assert auth.can_contribute_in_project(contributor, "default") is False + + # deployment owner is a superuser regardless of membership. + assert auth.can_read_project(owner, "default") is True + assert auth.is_project_superuser(owner, "default") is True + + # project_viewer → read + discuss, but not contribute. + _add_member("default", 1, "project_viewer") + assert auth.can_read_project(contributor, "default") is True + assert auth.can_discuss_in_project(contributor, "default") is True + assert auth.can_contribute_in_project(contributor, "default") is False + + # project_contributor → contribute. + _add_member("default", 1, "project_contributor") + assert auth.can_contribute_in_project(contributor, "default") is True + assert auth.is_project_superuser(contributor, "default") is False + + # project_admin → superuser within the project. + _add_member("default", 1, "project_admin") + assert auth.is_project_superuser(contributor, "default") is True + + +# --------------------------------------------------------------------------- +# 2. Public default unchanged — the regression floor (curation preserved) +# --------------------------------------------------------------------------- + + +def test_public_per_rfc_curation_preserved(app_with_fake_gitea): + """On the public default project a granted contributor still cannot + discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract); + but an unclaimed (no-owners) entry stays open. This is the implicit-public + baseline with curation preserved.""" + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="bob", role="contributor") + bob = _su(2, "bob", "contributor") + + _seed_rfc("owned", state="active", owners=["alice"]) + assert auth.can_discuss_rfc(bob, "owned") is False + assert auth.can_contribute_to_rfc(bob, "owned") is False + + _seed_rfc("draft", state="super-draft", owners=[]) + assert auth.can_discuss_rfc(bob, "draft") is True + assert auth.can_contribute_to_rfc(bob, "draft") is True + + +def test_public_read_open_write_gated_endpoints(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="bob", role="contributor") + _seed_rfc("ohm", state="active", owners=["alice"]) + + # anonymous can read the entry on a public project. + r = client.get("/api/rfcs/ohm") + assert r.status_code == 200, r.text + + # anonymous cannot open a discussion thread (401, the §6 write floor). + r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"}) + assert r.status_code == 401 + + +# --------------------------------------------------------------------------- +# 3. The §22.5 visibility gate — 404 to non-members on read +# --------------------------------------------------------------------------- + + +def test_gated_project_404s_non_members(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="bob", role="contributor") + provision_user_row(user_id=3, login="ben", role="owner") + _seed_rfc("sekret", state="active", owners=["alice"]) + _set_visibility("default", "gated") + + # anonymous → 404 (indistinguishable from an unknown slug). + assert client.get("/api/rfcs/sekret").status_code == 404 + + # signed-in non-member → 404 on the entry and its discussion. + sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") + assert client.get("/api/rfcs/sekret").status_code == 404 + assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404 + # the gated entry never surfaces in the non-member's catalog. + assert client.get("/api/rfcs").json()["items"] == [] + + # a project_viewer member can read again. + _add_member("default", 2, "project_viewer") + assert client.get("/api/rfcs/sekret").status_code == 200 + assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"] + + # a deployment owner can always read. + sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner") + assert client.get("/api/rfcs/sekret").status_code == 200 + + +# --------------------------------------------------------------------------- +# 4. The contribution gate on a gated project (401/403) +# --------------------------------------------------------------------------- + + +def test_gated_propose_requires_project_contributor(app_with_fake_gitea): + """Propose checks project-level contribute standing before any Gitea + work, so the gate is observable as a 403 (gated, non-member) without + seeding the success path.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="bob", role="contributor") + _set_visibility("default", "gated") + sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") + + body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []} + assert client.post("/api/rfcs/propose", json=body).status_code == 403 + + # grant project_contributor — the project gate now passes (the request + # proceeds past the gate; we assert only that it is no longer 403). + _add_member("default", 2, "project_contributor") + assert client.post("/api/rfcs/propose", json=body).status_code != 403 + + +def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="bob", role="contributor") + _seed_rfc("spec", state="super-draft", owners=[]) + _set_visibility("default", "gated") + bob = _su(2, "bob", "contributor") + + # non-member: discussion thread create → 404 (visibility, before the + # write gate even applies). + sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") + assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404 + + # project_viewer: can discuss (200) but cannot contribute (resolver). + _add_member("default", 2, "project_viewer") + r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}) + assert r.status_code == 200, r.text + assert auth.can_contribute_to_rfc(bob, "spec") is False + + # project_contributor: can contribute. + _add_member("default", 2, "project_contributor") + assert auth.can_contribute_to_rfc(bob, "spec") is True + + +# --------------------------------------------------------------------------- +# 5. Union of tiers + subtractive visibility gate +# --------------------------------------------------------------------------- + + +def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea): + from app import auth + from test_propose_vertical import grant_rfc_collaborator + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=2, login="bob", role="contributor") + _seed_rfc("owned", state="active", owners=["alice"]) + bob = _su(2, "bob", "contributor") + + # public + per-RFC collaborator(contributor) → contribute (union term). + grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor") + assert auth.can_contribute_to_rfc(bob, "owned") is True + + # flip to gated: the §22.5 gate is subtractive — the per-RFC grant no + # longer suffices without project membership. + _set_visibility("default", "gated") + assert auth.can_contribute_to_rfc(bob, "owned") is False + + # restore read via project membership → the per-RFC union applies again. + _add_member("default", 2, "project_viewer") + assert auth.can_contribute_to_rfc(bob, "owned") is True + + +# --------------------------------------------------------------------------- +# 6. Revocation takes effect on the next call +# --------------------------------------------------------------------------- + + +def test_revoking_membership_revokes_access(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=2, login="bob", role="contributor") + _set_visibility("default", "gated") + bob = _su(2, "bob", "contributor") + + _add_member("default", 2, "project_contributor") + assert auth.can_contribute_in_project(bob, "default") is True + + _remove_member("default", 2) + assert auth.can_read_project(bob, "default") is False + assert auth.can_contribute_in_project(bob, "default") is False diff --git a/docs/design/multi-project-spec.md b/docs/design/multi-project-spec.md index f1c1191..2493e1a 100644 --- a/docs/design/multi-project-spec.md +++ b/docs/design/multi-project-spec.md @@ -484,13 +484,32 @@ single project, with the spine underneath. Additive only: no table rebuilds project #2, enumerated in migration 025's header). This is the foundation everything after builds on. -**M2 — Project-scoped authorization + the §22.7 resolver.** The three-tier -composition: `project_members` roles, the most-permissive union with -deployment role and per-RFC authority, the §22.5 visibility gate as a 404 on -read and 401/403 on write. Every §17 write endpoint surveyed in §6.1's audit -re-checked under the project axis. Still single visible project; this slice -is verifiable by granting/revoking roles on the default project and asserting -the gates. +**M2 — Project-scoped authorization + the §22.7 resolver.** *(landed)* +The three-tier composition: `project_members` roles, the most-permissive +union with deployment role and per-RFC authority, the §22.5 visibility gate as +a 404 on read and 401/403 on write. Every §17 write endpoint surveyed in +§6.1's audit re-checked under the project axis. Still single visible project; +verifiable by granting/revoking roles on the default project and flipping its +visibility (`backend/tests/test_multi_project_authz_vertical.py`). Pure +app-layer — the resolver primitives live in `app/auth.py` +(`project_visibility`, `project_member_role`, `is_project_superuser`, +`can_read_project`, `can_contribute_in_project`, `require_project_readable`, +`visible_project_ids`), composed into the existing per-RFC capability helpers +and threaded into every RFC-resolution gate (`_require_rfc*`, +`_require_super_draft`, `_require_rfc_readable`, the branch/PR/graduation deep +gates). No migration (M1 shipped the tables) and no behavior change on the +public default project. + +Two operator decisions pin how the implicit grant behaves on a `public` +project: **implicit-on-public** — a granted deployment `contributor` keeps its +pre-multi-project write baseline with no `project_members` row, so the N=1 case +stays whole (no backfill); and **preserve curation** — that implicit baseline +does *not* override per-RFC owner curation (only an explicit +project_contributor/admin or a deployment owner/admin does), so the v0.16.0 +per-RFC invite contract is unchanged on public. Explicit `project_members` +rows and `gated`/`unlisted` visibility are where the new tier bites. This is a +deliberate liberalization of §22.5's literal "contributing still requires a +grant" for the public case; gated/unlisted honor the grant model exactly. **M3 — Registry mirror + routing + runtime branding.** The §4 registry mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects`