503689bf1a
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>
348 lines
14 KiB
Python
348 lines
14 KiB
Python
"""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
|