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.
|
||||
|
||||
Reference in New Issue
Block a user