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>
636 lines
26 KiB
Python
636 lines
26 KiB
Python
"""Gitea OAuth and user provisioning.
|
|
|
|
OAuth identity is the basis for the app's user account per §18; the §6
|
|
authorization layer is built on top by reading from the users table. On
|
|
first sign-in we insert a row with role='contributor' (or 'owner' if the
|
|
gitea_login matches the configured OWNER_GITEA_LOGIN — bootstrapping for
|
|
owner zero per §6.1). On subsequent sign-ins we refresh the display name
|
|
and avatar from Gitea so a rename in Gitea propagates here.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import secrets
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import HTTPException, Request
|
|
|
|
from . import db
|
|
from .bot import Actor
|
|
from .config import Config
|
|
from .projects import DEFAULT_PROJECT_ID
|
|
|
|
|
|
@dataclass
|
|
class SessionUser:
|
|
user_id: int
|
|
gitea_id: int
|
|
gitea_login: str
|
|
display_name: str
|
|
email: str
|
|
avatar_url: str
|
|
role: str
|
|
# v0.8.0 / §6.1 — admission gate. Three states: 'pending' (waiting
|
|
# for an admin grant), 'granted' (active contributor), 'revoked'
|
|
# (was granted, later removed). Existing rows at migration time
|
|
# default to 'granted' so grandfathered users are unaffected; OTC
|
|
# provisions fresh users with 'pending' (see `app/otc.py`).
|
|
permission_state: str = "granted"
|
|
|
|
def as_actor(self) -> Actor:
|
|
return Actor(
|
|
user_id=self.user_id,
|
|
gitea_login=self.gitea_login,
|
|
display_name=self.display_name,
|
|
email=self.email,
|
|
)
|
|
|
|
|
|
def authorization_url(config: Config, state: str) -> str:
|
|
return (
|
|
f"{config.gitea_url}/login/oauth/authorize"
|
|
f"?client_id={config.oauth_client_id}"
|
|
f"&redirect_uri={config.redirect_uri}"
|
|
f"&response_type=code"
|
|
f"&state={state}"
|
|
)
|
|
|
|
|
|
async def exchange_code(config: Config, code: str) -> dict[str, Any]:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.post(
|
|
f"{config.gitea_url}/login/oauth/access_token",
|
|
json={
|
|
"client_id": config.oauth_client_id,
|
|
"client_secret": config.oauth_client_secret,
|
|
"code": code,
|
|
"grant_type": "authorization_code",
|
|
"redirect_uri": config.redirect_uri,
|
|
},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
async def fetch_user_profile(config: Config, access_token: str) -> dict[str, Any]:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.get(
|
|
f"{config.gitea_url}/api/v1/user",
|
|
headers={"Authorization": f"token {access_token}"},
|
|
)
|
|
resp.raise_for_status()
|
|
return resp.json()
|
|
|
|
|
|
def allowlist_is_active() -> bool:
|
|
"""The private-beta gate is on iff the `allowed_emails` table has any
|
|
rows. Empty list means "open" — any successful OAuth provisions a
|
|
user; first row added flips the deployment into private-beta mode.
|
|
See `migrations/011_allowlist.sql` for the reasoning.
|
|
"""
|
|
row = db.conn().execute("SELECT 1 FROM allowed_emails LIMIT 1").fetchone()
|
|
return row is not None
|
|
|
|
|
|
def is_allowed_sign_in(profile: dict[str, Any]) -> bool:
|
|
"""Decide whether a freshly-completed OAuth profile may sign in.
|
|
|
|
v0.8.0 (item #6) replaces the allowlist gate with an admin-grant
|
|
flow at the OTC `/request` surface, but the Gitea OAuth callback
|
|
in `main.py` still consults this helper so the fallback path
|
|
keeps the v0.3.0 admission shape during the OAuth migration
|
|
window. The eventual removal of the OAuth callback (§19.2)
|
|
retires this function alongside it.
|
|
|
|
Three accept paths:
|
|
1. The allowlist is empty (gate off).
|
|
2. The Gitea profile's email is in `allowed_emails` (case-insensitive).
|
|
3. A `users` row already exists for this `gitea_id` — grandfather
|
|
per `migrations/011_allowlist.sql`.
|
|
"""
|
|
gitea_id = profile.get("id")
|
|
if gitea_id is not None:
|
|
existing = db.conn().execute(
|
|
"SELECT 1 FROM users WHERE gitea_id = ? LIMIT 1", (gitea_id,)
|
|
).fetchone()
|
|
if existing is not None:
|
|
return True
|
|
if not allowlist_is_active():
|
|
return True
|
|
email = (profile.get("email") or "").strip()
|
|
if not email:
|
|
return False
|
|
row = db.conn().execute(
|
|
"SELECT 1 FROM allowed_emails WHERE email = ? LIMIT 1", (email,)
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def provision_user(config: Config, profile: dict[str, Any]) -> SessionUser:
|
|
"""Insert or update the users row for this Gitea profile.
|
|
|
|
Owner zero (§6.1) is whoever's gitea_login matches OWNER_GITEA_LOGIN.
|
|
The owner role is granted on first sign-in and never revoked from a
|
|
later config change — once owner, always owner until an explicit
|
|
role transition (which lives in §6.1 and isn't part of slice 1).
|
|
"""
|
|
gitea_id = profile["id"]
|
|
login = profile["login"]
|
|
display = profile.get("full_name") or login
|
|
email = profile.get("email") or ""
|
|
avatar = profile.get("avatar_url") or ""
|
|
|
|
c = db.conn()
|
|
existing = c.execute("SELECT * FROM users WHERE gitea_id = ?", (gitea_id,)).fetchone()
|
|
if existing is None:
|
|
role = "owner" if config.owner_gitea_login and login == config.owner_gitea_login else "contributor"
|
|
# v0.8.0: a fresh OAuth-provisioned user is also subject to
|
|
# the admin-grant flow. The OAuth fallback only fires for
|
|
# users who pass `is_allowed_sign_in` (so they're already on
|
|
# the legacy allowlist or are grandfathered by gitea_id);
|
|
# 'granted' is the right default here since the allowlist
|
|
# check is itself the admin gesture. A future release that
|
|
# retires the OAuth callback (§19.2) collapses both paths
|
|
# under the same gate.
|
|
cur = c.execute(
|
|
"""
|
|
INSERT INTO users (gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state)
|
|
VALUES (?, ?, ?, ?, ?, ?, 'granted')
|
|
""",
|
|
(gitea_id, login, email, display, avatar, role),
|
|
)
|
|
user_id = cur.lastrowid
|
|
permission_state = "granted"
|
|
else:
|
|
user_id = existing["id"]
|
|
role = existing["role"]
|
|
permission_state = existing["permission_state"] or "granted"
|
|
c.execute(
|
|
"""
|
|
UPDATE users
|
|
SET gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, last_seen_at = datetime('now')
|
|
WHERE id = ?
|
|
""",
|
|
(login, email, display, avatar, user_id),
|
|
)
|
|
|
|
return SessionUser(
|
|
user_id=user_id,
|
|
gitea_id=gitea_id,
|
|
gitea_login=login,
|
|
display_name=display,
|
|
email=email,
|
|
avatar_url=avatar,
|
|
role=role,
|
|
permission_state=permission_state,
|
|
)
|
|
|
|
|
|
# ----- Session helpers -----
|
|
|
|
SESSION_USER_KEY = "user"
|
|
SESSION_STATE_KEY = "oauth_state"
|
|
|
|
|
|
def store_session(request: Request, user: SessionUser) -> None:
|
|
request.session[SESSION_USER_KEY] = {
|
|
"user_id": user.user_id,
|
|
"gitea_id": user.gitea_id,
|
|
"gitea_login": user.gitea_login,
|
|
"display_name": user.display_name,
|
|
"email": user.email,
|
|
"avatar_url": user.avatar_url,
|
|
"role": user.role,
|
|
# v0.8.0: persist the admission state on the cookie payload so
|
|
# the post-cookie audit doesn't second-guess the row. The DB
|
|
# is re-read on every `current_user` call regardless (so an
|
|
# admin grant takes effect on the next request); this field
|
|
# is purely structural redundancy for the cookie shape.
|
|
"permission_state": user.permission_state,
|
|
}
|
|
|
|
|
|
def current_user(request: Request) -> SessionUser | None:
|
|
raw = request.session.get(SESSION_USER_KEY)
|
|
if not raw:
|
|
return None
|
|
# Re-read the role from the database every request so role changes
|
|
# take effect on the next API call without forcing a logout.
|
|
row = db.conn().execute(
|
|
"SELECT id, gitea_id, gitea_login, email, display_name, avatar_url, role, permission_state FROM users WHERE id = ?",
|
|
(raw["user_id"],),
|
|
).fetchone()
|
|
if row is None:
|
|
return None
|
|
# v0.7.0: OTC-provisioned users have NULL gitea_id / gitea_login.
|
|
# Coerce nulls to the SessionUser's typed defaults so downstream
|
|
# code (Actor, _on_behalf_trailer) reads a stable shape regardless
|
|
# of which sign-in path the row came from. The DB remains the
|
|
# source of truth for "is this an OAuth-linked user" (gitea_id IS
|
|
# NOT NULL); the in-memory SessionUser is the per-request handle.
|
|
# v0.8.0: permission_state comes off the row directly. A NULL
|
|
# column value (shouldn't happen under the migration's
|
|
# NOT NULL DEFAULT, but be defensive) reads as 'granted' so the
|
|
# gate fails open for grandfathered surfaces rather than locking
|
|
# everyone out on a malformed row.
|
|
return SessionUser(
|
|
user_id=row["id"],
|
|
gitea_id=row["gitea_id"] or 0,
|
|
gitea_login=row["gitea_login"] or "",
|
|
display_name=row["display_name"],
|
|
email=row["email"] or "",
|
|
avatar_url=row["avatar_url"] or "",
|
|
role=row["role"],
|
|
permission_state=row["permission_state"] or "granted",
|
|
)
|
|
|
|
|
|
def require_user(request: Request) -> SessionUser:
|
|
user = current_user(request)
|
|
if user is None:
|
|
raise HTTPException(status_code=401, detail="Not authenticated")
|
|
return user
|
|
|
|
|
|
def require_contributor(request: Request) -> SessionUser:
|
|
"""§6.1: authenticated, not write-muted, and granted by an admin.
|
|
|
|
v0.8.0 (item #6) widens this gate. A fresh OTC sign-in lands in
|
|
`permission_state='pending'`; the user can read everything an
|
|
anonymous viewer can read, but every write-shaped endpoint that
|
|
funnels through this dependency now refuses with 403 until an
|
|
admin grants them. The `pending` blast radius is the same as
|
|
anonymous (item #4 / v0.6.0 already audited the anon-write
|
|
refusal at every write site), so this widening is structurally
|
|
a relabel — the same surfaces that already refused 401 to
|
|
anonymous now also refuse 403 to pending.
|
|
"""
|
|
user = require_user(request)
|
|
row = db.conn().execute("SELECT muted FROM users WHERE id = ?", (user.user_id,)).fetchone()
|
|
if row and row["muted"]:
|
|
raise HTTPException(status_code=403, detail="Your account is muted")
|
|
if user.permission_state != "granted":
|
|
# 'pending' is the post-OTC waiting state; 'revoked' is the
|
|
# admin-undid-the-grant state. Both refuse with the same 403
|
|
# shape; the client distinguishes via `/api/auth/me` which
|
|
# carries `permission_state` in the response.
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Your beta access request is in review",
|
|
)
|
|
return user
|
|
|
|
|
|
def require_admin(request: Request) -> SessionUser:
|
|
"""§6.1: owner or admin."""
|
|
user = require_user(request)
|
|
if user.role not in ("owner", "admin"):
|
|
raise HTTPException(status_code=403, detail="Admin or owner role required")
|
|
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
|
|
# endpoints that an RFC's owner can selectively open up. The "discussion"
|
|
# and "PR" write surfaces consult `is_rfc_writer(...)` / `is_rfc_discussant(...)`
|
|
# to admit users who are either platform-privileged (admin, RFC owner)
|
|
# OR who hold an explicit invitation-accepted per-RFC role.
|
|
#
|
|
# The platform gate still fires first: a user whose
|
|
# `permission_state != 'granted'` cannot write anywhere, invitation or
|
|
# not. v0.16.0 doesn't loosen that — a per-RFC invitation is additive
|
|
# *within* the granted-platform-user population. (Accepting an
|
|
# invitation as a pending user surfaces in the admin-page hook per
|
|
# the roadmap text; the platform grant remains the admin's decision.)
|
|
|
|
|
|
def _rfc_owners_set(rfc_slug: str) -> set[str]:
|
|
"""The gitea_logins named in the RFC's frontmatter owners array.
|
|
|
|
Read from `cached_rfcs.owners_json`. Returns an empty set if the RFC
|
|
isn't cached (the caller's earlier `_require_rfc_readable` will
|
|
already have rejected that case in practice).
|
|
"""
|
|
import json as _json
|
|
row = db.conn().execute(
|
|
"SELECT owners_json FROM cached_rfcs WHERE slug = ?", (rfc_slug,),
|
|
).fetchone()
|
|
if row is None:
|
|
return set()
|
|
try:
|
|
return set(_json.loads(row["owners_json"] or "[]"))
|
|
except Exception:
|
|
return set()
|
|
|
|
|
|
def is_rfc_owner(user: SessionUser | None, rfc_slug: str) -> bool:
|
|
"""True iff the user is named in the RFC's frontmatter `owners`
|
|
list. The platform-level admin/owner check is separate; per §6.1 an
|
|
app admin/owner has all per-RFC capabilities by construction, but
|
|
this predicate is intentionally narrow — it answers "is this
|
|
person on the RFC's owners line?" and nothing more.
|
|
"""
|
|
if user is None:
|
|
return False
|
|
return user.gitea_login in _rfc_owners_set(rfc_slug)
|
|
|
|
|
|
def is_rfc_collaborator(user: SessionUser | None, rfc_slug: str, *, role_in_rfc: str | None = None) -> bool:
|
|
"""True iff the user has an accepted per-RFC collaborator row.
|
|
|
|
`role_in_rfc`:
|
|
* None — any role qualifies (the discussion-write check uses this
|
|
shape: contributor strictly includes discussant).
|
|
* 'contributor' — only the contributor role qualifies (the PR-write
|
|
check uses this shape).
|
|
* 'discussant' — only the discussant role qualifies (not used by
|
|
v0.16.0 endpoints; included for symmetry).
|
|
"""
|
|
if user is None:
|
|
return False
|
|
if role_in_rfc is None:
|
|
row = db.conn().execute(
|
|
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? LIMIT 1",
|
|
(rfc_slug, user.user_id),
|
|
).fetchone()
|
|
return row is not None
|
|
row = db.conn().execute(
|
|
"SELECT 1 FROM rfc_collaborators WHERE rfc_slug = ? AND user_id = ? AND role_in_rfc = ? LIMIT 1",
|
|
(rfc_slug, user.user_id, role_in_rfc),
|
|
).fetchone()
|
|
return row is not None
|
|
|
|
|
|
def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
|
|
"""v0.16.0 — admit to PR-less discussion writes on this RFC.
|
|
|
|
True if ANY of:
|
|
* platform admin/owner (the §6.1 maximal-capability path),
|
|
* the RFC has no frontmatter owners yet (the gate is open
|
|
until an owner exists to set it — relevant for super-drafts
|
|
pre-§13.1 claim),
|
|
* RFC owner (frontmatter `owners` membership),
|
|
* accepted per-RFC collaborator at any role (contributor strictly
|
|
includes discussant).
|
|
|
|
Returns False for anonymous viewers and for users whose
|
|
`permission_state != 'granted'` — the platform-level gate must hold
|
|
before any per-RFC layer can apply. The platform gate is also
|
|
enforced earlier in the request via `require_contributor`; the
|
|
helper here is defensive so callers that compose it with
|
|
`current_user` directly still respect the gate.
|
|
"""
|
|
if user is None:
|
|
return False
|
|
if user.permission_state != "granted":
|
|
return False
|
|
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 user.gitea_login in owners:
|
|
return True
|
|
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:
|
|
"""v0.16.0 — admit to PR-shaped writes on this RFC.
|
|
|
|
True if ANY of:
|
|
* platform admin/owner,
|
|
* the RFC has no frontmatter owners yet (gate open until an
|
|
owner exists),
|
|
* RFC owner,
|
|
* accepted per-RFC collaborator at role 'contributor' (a
|
|
'discussant' row is NOT sufficient — PRs are the
|
|
higher-privilege surface).
|
|
|
|
Same `permission_state` and anonymous-viewer refusals as
|
|
`can_discuss_rfc`.
|
|
"""
|
|
if user is None:
|
|
return False
|
|
if user.permission_state != "granted":
|
|
return False
|
|
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 user.gitea_login in owners:
|
|
return True
|
|
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:
|
|
"""v0.16.0 — only RFC owners (frontmatter) and platform admin/owner
|
|
can issue invitations. Per-RFC collaborators do not get the
|
|
invite-others power; that stays with the RFC's owner."""
|
|
if user is None:
|
|
return False
|
|
if user.permission_state != "granted":
|
|
return False
|
|
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)
|
|
|
|
|
|
def new_state() -> str:
|
|
return secrets.token_urlsafe(16)
|