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:
+34
-7
@@ -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.
|
||||
|
||||
+60
-38
@@ -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 "[]")
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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 "[]")
|
||||
|
||||
@@ -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 "[]")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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 "[]")
|
||||
|
||||
+202
-15
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user