feat(projects): M2 — project-scoped authorization + the §22.7 resolver (§22)
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 <noreply@anthropic.com>
This commit is contained in:
+17
-14
@@ -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 "[]")
|
||||
|
||||
Reference in New Issue
Block a user