Files
rfc-app/backend/app/api_contributions.py
T
Ben Stull c2f566512a §22 S3: scope-role enforcement + collection-grain visibility (@S3) — v0.42.0
Implement slice S3 of the §22 three-tier refactor: the four-layer
most-permissive scope-role resolver (§B.2) over {owner, contributor}
grants at {global, project, collection}, with the §22.5 visibility gate
enforced at the collection grain.

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 18:07:51 -07:00

317 lines
13 KiB
Python

"""v0.29.0 / roadmap #28 Part 3 — offer-to-contribute-to-a-pending-RFC.
When the #28 scanner (see ``rfc_links.py``) matches a term in submitted
PR/comment text to a **pending** RFC — a super-draft
(``cached_rfcs.state='super-draft'``: accepted as an idea, owned, with a
contribution surface, but not yet graduated to an active RFC) — the
reader is offered an "ask to contribute" popover. This module is the
backend for that flow:
* ``GET /api/rfcs/{slug}/contribution-target`` — what the
contribute form needs (RFC title, owner display, the viewer's
eligibility + whether they already have a pending ask).
* ``POST /api/rfcs/{slug}/contribution-requests`` — submit the ask
(who-I-am / why / optional use-case); lands a row + one §15
notification per owner.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/accept`` — owner:
accept, which fires #12's owner-invite flow with the requester as the
invitee (opening the RFC's discussion/contribution surface), then
echoes a notification back to the requester.
* ``POST /api/rfcs/{slug}/contribution-requests/{id}/decline`` — owner:
decline; the request closes and the requester is notified.
"Pending" is scoped to a super-draft because that is the state with an
owner to route to, a contribution surface to open, and a row in
``cached_rfcs`` for the ``rfc_invitations`` FK the accept path reuses.
Pre-merge idea PRs are deliberately out of scope (no contribution
surface yet) — a documented future extension, mirroring the
conservative scoping in ``rfc_links.py``.
"""
from __future__ import annotations
import sqlite3
from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from . import api_invitations, auth, db, notify
# Field caps — generous for free text, bounded so a request row (and the
# notification payload that carries it) can't be used to store unbounded
# blobs. Mirrors the order-of-magnitude of the propose/tag-suggest caps.
_WHO_MAX = 2000
_WHY_MAX = 4000
_USE_CASE_MAX = 4000
_TERM_MAX = 200
class ContributionRequestBody(BaseModel):
# The term in the PR/comment text that surfaced the offer (the
# super-draft's title/slug). Carried for the owner's context line.
matched_term: str = Field(min_length=1, max_length=_TERM_MAX)
who_i_am: str = Field(min_length=1, max_length=_WHO_MAX)
why: str = Field(min_length=1, max_length=_WHY_MAX)
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
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). 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, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS 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
def _require_request(slug: str, request_id: int):
row = db.conn().execute(
"""
SELECT id, rfc_slug, requester_user_id, matched_term, who_i_am, why,
use_case, status
FROM contribution_requests WHERE id = ? AND rfc_slug = ?
""",
(request_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Contribution request not found")
return row
def _viewer_relationship(viewer, slug: str) -> str | None:
"""Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request."""
if auth.is_rfc_owner(viewer, slug) or auth.is_collection_superuser(viewer, auth.collection_of_rfc(slug)):
return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC."
return None
def make_router() -> APIRouter:
router = APIRouter()
# ---------------------------------------------------------------
# GET — what the contribute form needs to render + gate itself.
# ---------------------------------------------------------------
@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, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS 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"])
eligible = True
reason: str | None = None
already_requested = False
if row["state"] != "super-draft":
eligible, reason = False, "This RFC is no longer pending."
elif viewer is None:
eligible, reason = False, "Sign in to ask to contribute."
elif viewer.permission_state != "granted":
eligible, reason = False, "Your beta access request is in review."
else:
reason = _viewer_relationship(viewer, slug)
if reason is not None:
eligible = False
else:
already_requested = bool(
db.conn().execute(
"""
SELECT 1 FROM contribution_requests
WHERE rfc_slug = ? AND requester_user_id = ? AND status = 'pending'
LIMIT 1
""",
(slug, viewer.user_id),
).fetchone()
)
return {
"slug": row["slug"],
"title": row["title"],
"owner": owner,
"eligible": eligible and not already_requested,
"reason": reason,
"already_requested": already_requested,
}
# ---------------------------------------------------------------
# POST — submit a contribute request.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests")
async def create_contribution_request(
slug: str, body: ContributionRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug, viewer)
reason = _viewer_relationship(viewer, slug)
if reason is not None:
raise HTTPException(409, reason)
who_i_am = body.who_i_am.strip()
why = body.why.strip()
use_case = (body.use_case or "").strip() or None
matched_term = body.matched_term.strip()
if not who_i_am or not why:
raise HTTPException(422, "Both 'who I am' and 'why' are required.")
try:
cur = db.conn().execute(
"""
INSERT INTO contribution_requests
(rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case)
VALUES (?, ?, ?, ?, ?, ?)
""",
(slug, viewer.user_id, matched_term, who_i_am, why, use_case),
)
except sqlite3.IntegrityError:
# The partial unique index — one open request per (RFC, user).
raise HTTPException(409, "You already have a pending request to contribute to this RFC.")
request_id = cur.lastrowid
# One actionable notification per owner; stamp the first onto the
# row as the inbox-action handle (any owner can act on the request).
notif_ids = notify.fan_out_contribution_request(
rfc_slug=slug,
requester_user_id=viewer.user_id,
request_id=request_id,
matched_term=matched_term,
who_i_am=who_i_am,
why=why,
use_case=use_case,
)
if notif_ids:
db.conn().execute(
"UPDATE contribution_requests SET notification_id = ? WHERE id = ?",
(notif_ids[0], request_id),
)
return {"id": request_id, "rfc_slug": slug, "status": "pending"}
# ---------------------------------------------------------------
# POST — owner accepts → fire #12's invite flow.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/accept")
async def accept_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
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")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
requester = db.conn().execute(
"SELECT id, email FROM users WHERE id = ?", (req["requester_user_id"],)
).fetchone()
if requester is None or not (requester["email"] or "").strip():
raise HTTPException(422, "The requester has no email address on file to invite.")
# Fire #12's owner-invite flow with the requester as the invitee.
# If a pending contributor invitation already exists (the owner
# invited them out-of-band first), reuse it rather than failing.
try:
invitation = api_invitations.issue_invitation(
slug=slug,
inviter_user_id=viewer.user_id,
inviter_display=viewer.display_name or viewer.gitea_login or "An RFC owner",
invitee_email=requester["email"],
role_in_rfc="contributor",
rfc_title=rfc["title"],
)
invitation_id = invitation["id"]
except HTTPException as exc:
if exc.status_code != 409:
raise
existing = db.conn().execute(
"""
SELECT id FROM rfc_invitations
WHERE rfc_slug = ? AND invitee_email = ? COLLATE NOCASE
AND role_in_rfc = 'contributor' AND status = 'pending'
ORDER BY id DESC LIMIT 1
""",
(slug, requester["email"].strip()),
).fetchone()
invitation_id = existing["id"] if existing else None
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'accepted', decided_at = datetime('now'),
decided_by_user_id = ?, invitation_id = ?
WHERE id = ?
""",
(viewer.user_id, invitation_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=True,
)
return {"ok": True, "status": "accepted", "invitation_id": invitation_id}
# ---------------------------------------------------------------
# POST — owner declines.
# ---------------------------------------------------------------
@router.post("/api/rfcs/{slug}/contribution-requests/{request_id}/decline")
async def decline_contribution_request(
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_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")
req = _require_request(slug, request_id)
if req["status"] != "pending":
raise HTTPException(409, f"This request was already {req['status']}.")
db.conn().execute(
"""
UPDATE contribution_requests
SET status = 'declined', decided_at = datetime('now'),
decided_by_user_id = ?
WHERE id = ?
""",
(viewer.user_id, request_id),
)
notify.notify_contribution_decided(
rfc_slug=slug,
requester_user_id=req["requester_user_id"],
decider_user_id=viewer.user_id,
request_id=request_id,
accepted=False,
)
return {"ok": True, "status": "declined"}
return router