f0533dc073
The Gitea-OAuth callback's `auth.provision_user` matched an existing `users` row only by `gitea_id`. A human who signed in first via OTC owns an email-only row (`gitea_id` NULL); a later Gitea-OAuth sign-in with the same email missed that row and INSERTed a new one, colliding on the `idx_users_email` case-insensitive unique index → IntegrityError → 500 callback. Fix (mirror of `otc.provision_or_link_user`): when no `gitea_id` row exists, reconcile by email and link the OAuth identity (gitea_id/gitea_login/profile) onto the existing email row. Owner-zero (§6.1) bootstrap applied on link (matching the fresh-insert path); `permission_state` preserved so linking never changes admission status as a side effect. One §9-surfaced framework fragility of two. backend 680 green (+3). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
966 lines
43 KiB
Python
966 lines
43 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 collections as collections_mod
|
||
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()
|
||
# No row for this gitea_id yet. A prior OTC sign-in (`provision_or_link_user`)
|
||
# may have created an email-only row with this same email and `gitea_id`
|
||
# NULL; `idx_users_email` is a unique index, so a blind INSERT below would
|
||
# raise IntegrityError and 500 the OAuth callback. Reconcile by email: link
|
||
# the OAuth identity onto that existing row instead. Mirror of the OTC
|
||
# linker, which links an OAuth-era email row on first OTC sign-in.
|
||
linked = False
|
||
if existing is None and email:
|
||
existing = c.execute(
|
||
"SELECT * FROM users WHERE email = ? COLLATE NOCASE AND gitea_id IS NULL LIMIT 1",
|
||
(email,),
|
||
).fetchone()
|
||
linked = existing is not None
|
||
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"]
|
||
# On a fresh link this OAuth sign-in is the row's first, so apply the
|
||
# §6.1 owner-zero bootstrap (matching the INSERT path). For a row already
|
||
# matched by gitea_id the role is settled — preserve it. `permission_state`
|
||
# is preserved either way: linking an OAuth identity onto an existing
|
||
# email row must not silently change the human's admission status.
|
||
if linked and config.owner_gitea_login and login == config.owner_gitea_login:
|
||
role = "owner"
|
||
else:
|
||
role = existing["role"]
|
||
permission_state = existing["permission_state"] or "granted"
|
||
# Setting `gitea_id` matters only on the link path (it was NULL); on the
|
||
# gitea_id-matched path it re-writes the same value. `role` is likewise a
|
||
# no-op there. Always refresh the mutable profile fields + last_seen.
|
||
c.execute(
|
||
"""
|
||
UPDATE users
|
||
SET gitea_id = ?, gitea_login = ?, email = ?, display_name = ?, avatar_url = ?, role = ?, last_seen_at = datetime('now')
|
||
WHERE id = ?
|
||
""",
|
||
(gitea_id, login, email, display, avatar, role, 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 _is_default_project(project_id: str) -> bool:
|
||
"""True iff `project_id` owns the migration-seeded `default` collection — the
|
||
deployment's primary project (§22.13), whatever its configured id. Only there
|
||
do M2's role rows (which migrated to collection scope `default`) stand in for
|
||
project-level authority."""
|
||
return collections_mod.project_of_collection(collections_mod.DEFAULT_COLLECTION_ID) == project_id
|
||
|
||
|
||
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
|
||
"""The user's *project-grain* §22.6 role at this project, or None — the
|
||
most-permissive of a **global** grant (inherits down to every project) and a
|
||
**project**-scope grant. Mapped back to the legacy
|
||
`project_admin`/`project_contributor` strings the project-grain authz speaks.
|
||
|
||
Back-compat: on the deployment's *default* project only, M2's rows live at
|
||
collection scope `default` (§B.3 migration), so a `default` collection-scope
|
||
grant there is read as project-level too. A collection grant on any other
|
||
project is NOT project authority — that is the four-layer collection resolver
|
||
(`effective_scope_role`). Does not fold in the deployment tier
|
||
(`is_project_superuser` adds it) or the implicit-on-public baseline."""
|
||
if user is None:
|
||
return None
|
||
clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"]
|
||
params: list = [user.user_id, project_id]
|
||
if _is_default_project(project_id):
|
||
clauses.append("(scope_type = 'collection' AND scope_id = ?)")
|
||
params.append(collections_mod.DEFAULT_COLLECTION_ID)
|
||
row = db.conn().execute(
|
||
"SELECT role FROM memberships WHERE user_id = ? AND (" + " OR ".join(clauses) + ") "
|
||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||
params,
|
||
).fetchone()
|
||
if row is None:
|
||
return None
|
||
return "project_admin" if row["role"] == "owner" else "project_contributor"
|
||
|
||
|
||
def project_of_rfc(rfc_slug: str) -> str:
|
||
"""The project an RFC belongs to, via its collection
|
||
(`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier).
|
||
Falls back to the default project when the slug isn't cached — the same N=1
|
||
default migration 026 backfills."""
|
||
row = db.conn().execute(
|
||
"SELECT c.project_id AS project_id "
|
||
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
|
||
"WHERE r.slug = ?",
|
||
(rfc_slug,),
|
||
).fetchone()
|
||
if row is None:
|
||
return DEFAULT_PROJECT_ID
|
||
return row["project_id"] or DEFAULT_PROJECT_ID
|
||
|
||
|
||
def collection_of_rfc(rfc_slug: str) -> str:
|
||
"""The collection an RFC belongs to (`cached_rfcs.collection_id`). Falls back
|
||
to the default collection when the slug isn't cached. Mirrors
|
||
`project_of_rfc`'s first-match semantics; a slug shared across collections is
|
||
a known routing ambiguity (the RFC-grain helpers take a bare slug) resolved
|
||
by the collection-qualified routes in later slices."""
|
||
row = db.conn().execute(
|
||
"SELECT collection_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
|
||
).fetchone()
|
||
if row is None or not row["collection_id"]:
|
||
return collections_mod.DEFAULT_COLLECTION_ID
|
||
return row["collection_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 at the project grain. `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
|
||
holder of any scope grant reaching the project — a global grant, a project
|
||
grant, or membership at *any* collection within it (seeing a collection
|
||
implies seeing its project). 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 — scope-role holders + superusers only, subject to the §6 floor.
|
||
if user is None or user.permission_state != "granted":
|
||
return False
|
||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||
return True
|
||
row = db.conn().execute(
|
||
"SELECT 1 FROM memberships m WHERE m.user_id = ? AND ("
|
||
" m.scope_type = 'global'"
|
||
" OR (m.scope_type = 'project' AND m.scope_id = ?)"
|
||
" OR (m.scope_type = 'collection' AND m.scope_id IN "
|
||
" (SELECT id FROM collections WHERE project_id = ?))) LIMIT 1",
|
||
(user.user_id, project_id, project_id),
|
||
).fetchone()
|
||
return row 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"])]
|
||
|
||
|
||
# ===========================================================================
|
||
# §22 three-tier — S3. The four-layer scope-role resolver (§B.2) and the
|
||
# collection-grain visibility gate.
|
||
#
|
||
# A grant attaches the unified role {owner, contributor} at a scope: global,
|
||
# project, or collection (§B.1). Grants inherit downward, are additive, and
|
||
# admit no negative override (§B.2). Effective authority over a *collection* is
|
||
# the most-permissive union of the layers reaching it:
|
||
#
|
||
# global (users.role owner/admin ∪ memberships scope_type='global')
|
||
# ∪ project (memberships scope_type='project' at the collection's project)
|
||
# ∪ collection (memberships scope_type='collection' at the collection)
|
||
#
|
||
# minus the §22.5 visibility gate and §6.2 write-mute (subtractive, as today).
|
||
# Per-entry authority (owners / arbiters / rfc_collaborators) is a distinct,
|
||
# finer layer the RFC-grain helpers union in beneath collection.
|
||
#
|
||
# OPERATOR DECISIONS (S3, session 0076):
|
||
# * scope-role-primary — a plain granted account (users.role 'contributor', no
|
||
# membership) is a granted *account*, not a write-everywhere global role.
|
||
# Write standing comes from an explicit scope grant; the lone exception is the
|
||
# grandfathered implicit-public baseline below.
|
||
# * grandfathered baseline — the migration-seeded `default` collection keeps the
|
||
# pre-three-tier implicit-on-public write baseline (a granted deployment
|
||
# contributor may propose while it is public), so the N=1 deployment (§22.13)
|
||
# loses no capability. Every *explicitly-created* collection requires an
|
||
# explicit scope grant to write.
|
||
# * hidden-from-public — a `gated` collection is invisible to the public (404,
|
||
# omitted from the directory) yet visible to any scope-role holder reaching it
|
||
# (collection/project/global). A collection's visibility may be set only as
|
||
# strict or stricter than its project's (the rank ordering below).
|
||
# ===========================================================================
|
||
|
||
# The global scope is a single tier per deployment; its grant rows use this
|
||
# sentinel scope_id (migration 030).
|
||
GLOBAL_SCOPE_ID = "*"
|
||
|
||
# §22.5 visibility strictness on the public-exposure axis: `public` is least
|
||
# strict, `gated` most strict. A collection may narrow its project's visibility
|
||
# but never widen it (`rank(collection) >= rank(project)`).
|
||
_VISIBILITY_RANK = {"public": 0, "unlisted": 1, "gated": 2}
|
||
|
||
|
||
def visibility_rank(visibility: str | None) -> int:
|
||
"""The strictness rank of a §22.5 visibility (higher = stricter). An unknown
|
||
value reads as the strictest (`gated`) — the safe default."""
|
||
return _VISIBILITY_RANK.get(visibility or "", _VISIBILITY_RANK["gated"])
|
||
|
||
|
||
def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | None:
|
||
"""The most-permissive unified role ({'owner','contributor'}) the user holds
|
||
over `collection_id`, folding §B.2's global → project → collection layers.
|
||
Returns None when no scope grant reaches the collection. 'owner' outranks
|
||
'contributor'; there is no negative override (a parent grant is never
|
||
subtracted by a child). Subject to the §6 admission floor."""
|
||
if user is None or user.permission_state != "granted":
|
||
return None
|
||
# Global tier — a deployment owner/admin is a global Owner (§B.1).
|
||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||
return "owner"
|
||
pid = collections_mod.project_of_collection(collection_id)
|
||
row = db.conn().execute(
|
||
"SELECT role FROM memberships "
|
||
"WHERE user_id = ? AND ("
|
||
" scope_type = 'global'"
|
||
" OR (scope_type = 'project' AND scope_id = ?)"
|
||
" OR (scope_type = 'collection' AND scope_id = ?)) "
|
||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||
(user.user_id, pid, collection_id),
|
||
).fetchone()
|
||
return row["role"] if row else None
|
||
|
||
|
||
def _effective_project_role(user: SessionUser | None, project_id: str) -> str | None:
|
||
"""The most-permissive role the user holds *over a project* — folding the
|
||
global tier (deployment owner/admin, or a `scope_type='global'` grant) and a
|
||
`scope_type='project'` grant on this project. Unlike `effective_scope_role`
|
||
(which keys on a collection), this answers the project grain directly, for the
|
||
§22.8 request-to-join membership check. Subject to the §6 admission floor."""
|
||
if user is None or user.permission_state != "granted":
|
||
return None
|
||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||
return "owner"
|
||
row = db.conn().execute(
|
||
"SELECT role FROM memberships "
|
||
"WHERE user_id = ? AND ("
|
||
" scope_type = 'global'"
|
||
" OR (scope_type = 'project' AND scope_id = ?)) "
|
||
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
|
||
(user.user_id, project_id),
|
||
).fetchone()
|
||
return row["role"] if row else None
|
||
|
||
|
||
def effective_role_at_scope(
|
||
user: SessionUser | None, scope_type: str, scope_id: str
|
||
) -> str | None:
|
||
"""The most-permissive scope role the user holds over a `(scope_type,
|
||
scope_id)` target — the scope-grain twin of `effective_scope_role`. A
|
||
`collection` target folds global → project → collection (the existing
|
||
resolver); a `project` target folds global → project. Returns None when no
|
||
grant reaches the scope. Drives the §22.8 "already a member?" gate."""
|
||
if scope_type == "collection":
|
||
return effective_scope_role(user, scope_id)
|
||
if scope_type == "project":
|
||
return _effective_project_role(user, scope_id)
|
||
return None
|
||
|
||
|
||
def collection_visibility(collection_id: str) -> str:
|
||
"""The collection's own §22.5 visibility. A missing row reads as 'gated' —
|
||
an unknown collection is invisible rather than open."""
|
||
row = db.conn().execute(
|
||
"SELECT visibility FROM collections WHERE id = ?", (collection_id,)
|
||
).fetchone()
|
||
if row is None or not row["visibility"]:
|
||
return "gated"
|
||
return row["visibility"]
|
||
|
||
|
||
def effective_collection_visibility(collection_id: str) -> str:
|
||
"""The stricter of the collection's own visibility and its project's (§22.5
|
||
'both gates'). A collection is constrained to be ≥ its project in strictness,
|
||
but we max() defensively so a misconfigured looser collection can never widen
|
||
its project's gate."""
|
||
cvis = collection_visibility(collection_id)
|
||
pid = collections_mod.project_of_collection(collection_id)
|
||
pvis = project_visibility(pid) if pid else "gated"
|
||
return cvis if visibility_rank(cvis) >= visibility_rank(pvis) else pvis
|
||
|
||
|
||
def can_read_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||
"""§22.5 read/existence gate at the collection grain. `public`/`unlisted`
|
||
read by anyone (anonymous included — `unlisted` is link-only but the link
|
||
reads); `gated` ("hidden from public existence") reads only for a scope-role
|
||
holder over the collection (collection/project/global) or a deployment
|
||
owner/admin. The subtractive read gate — a gated collection 404s a
|
||
non-holder, indistinguishable from absent."""
|
||
vis = effective_collection_visibility(collection_id)
|
||
if vis in ("public", "unlisted"):
|
||
return True
|
||
if user is None or user.permission_state != "granted":
|
||
return False
|
||
return effective_scope_role(user, collection_id) is not None
|
||
|
||
|
||
def require_collection_readable(user: SessionUser | None, collection_id: str) -> None:
|
||
"""Raise 404 when the collection is not readable by this viewer (§22.5: a
|
||
hidden/gated collection is invisible to non-holders — the shape matches an
|
||
unknown collection)."""
|
||
if not can_read_collection(user, collection_id):
|
||
raise HTTPException(status_code=404, detail="Not found")
|
||
|
||
|
||
def is_collection_superuser(user: SessionUser | None, collection_id: str) -> bool:
|
||
"""Maximal authority over a collection: an effective scope role of 'owner'
|
||
reaching it (a collection Owner, a project Owner of its project, a global
|
||
Owner, or a deployment owner/admin). Subsumes the per-entry owners/arbiters
|
||
tier within the collection."""
|
||
return effective_scope_role(user, collection_id) == "owner"
|
||
|
||
|
||
def _has_collection_write_baseline(user: SessionUser | None, collection_id: str) -> bool:
|
||
"""The grandfathered implicit-on-public write baseline, narrowed to the
|
||
migration-seeded `default` collection (§22.13 N=1 case). A granted deployment
|
||
`contributor` keeps its pre-three-tier write standing on the default
|
||
collection while its effective visibility is public; every explicitly-created
|
||
collection requires an explicit scope grant (S3 operator decision)."""
|
||
if user is None or user.permission_state != "granted":
|
||
return False
|
||
if collection_id != collections_mod.DEFAULT_COLLECTION_ID:
|
||
return False
|
||
return user.role == "contributor" and effective_collection_visibility(collection_id) == "public"
|
||
|
||
|
||
def can_contribute_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||
"""May the user contribute *new* content to the collection (propose an entry)
|
||
— the collection-level contribute standing. The union of the scope-role grant
|
||
(owner/contributor reaching the collection) and the grandfathered default
|
||
baseline, subject to the visibility read gate."""
|
||
if user is None or user.permission_state != "granted":
|
||
return False
|
||
if not can_read_collection(user, collection_id):
|
||
return False
|
||
if effective_scope_role(user, collection_id) is not None:
|
||
return True
|
||
return _has_collection_write_baseline(user, collection_id)
|
||
|
||
|
||
def can_discuss_in_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||
"""May the user participate in discussion in the collection — the
|
||
collection-level discuss standing. A superset of contribute for this pass
|
||
(the read-only viewer tier is deferred, §B.3), so it mirrors
|
||
`can_contribute_in_collection`."""
|
||
return can_contribute_in_collection(user, collection_id)
|
||
|
||
|
||
def can_create_collection(user: SessionUser | None, project_id: str) -> bool:
|
||
"""May the user create a new collection in this project (§B.1)? Creating a
|
||
collection is a *project-level* action: a deployment owner/admin, or any
|
||
holder of a project-scope or global-scope grant (Owner OR RFC Contributor —
|
||
'anyone at the project level with permission to create a collection'). A
|
||
*collection*-scope grant cannot create sibling collections."""
|
||
if user is None or user.permission_state != "granted":
|
||
return False
|
||
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
|
||
return True
|
||
row = db.conn().execute(
|
||
"SELECT 1 FROM memberships "
|
||
"WHERE user_id = ? AND ("
|
||
" scope_type = 'global'"
|
||
" OR (scope_type = 'project' AND scope_id = ?)) LIMIT 1",
|
||
(user.user_id, project_id),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
def can_create_project(user: SessionUser | None) -> bool:
|
||
"""§22 S5 (§A.2 / §B.1): may the user create a new project? "+ New project"
|
||
is a **global-Owner** action — a deployment owner/admin (a global Owner per
|
||
§B.1) or a holder of an explicit `scope_type='global'` Owner grant. Creating
|
||
a project is deployment-level, so it is not reachable by a project- or
|
||
collection-scope grant nor by a global RFC Contributor (that role creates
|
||
collections, not projects). 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
|
||
row = db.conn().execute(
|
||
"SELECT 1 FROM memberships "
|
||
"WHERE user_id = ? AND scope_type = 'global' AND role = 'owner' LIMIT 1",
|
||
(user.user_id,),
|
||
).fetchone()
|
||
return row is not None
|
||
|
||
|
||
def can_invite_at_project(user: SessionUser | None, project_id: str) -> bool:
|
||
"""§22 S4 (C.2): may the user grant scope roles at this project (or at any
|
||
collection within it)? Managing membership is an *Owner* capability whose
|
||
reach covers the project — a deployment owner/admin, a global Owner, or this
|
||
project's Owner. An RFC Contributor does not manage membership (C.2.4); a
|
||
collection Owner's reach is its own collection only (C.2.3), so it is not
|
||
offered project-scope invites. Identical to `is_project_superuser` — the
|
||
invite gate IS "is an Owner over this project"."""
|
||
return is_project_superuser(user, project_id)
|
||
|
||
|
||
def can_invite_at_collection(user: SessionUser | None, collection_id: str) -> bool:
|
||
"""§22 S4 (C.2): may the user grant scope roles at this collection? An Owner
|
||
whose reach covers it — the collection's Owner, its project's Owner, a global
|
||
Owner, or a deployment owner/admin (`is_collection_superuser`). This is the
|
||
narrowest invite reach; a collection Owner who is nothing more may invite
|
||
here but not at the project or globally (C.2.3)."""
|
||
return is_collection_superuser(user, collection_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
|
||
cid = collection_of_rfc(rfc_slug)
|
||
# §22.5 visibility gate is subtractive (§22.7) — no capability in a
|
||
# collection the viewer cannot even read.
|
||
if not can_read_collection(user, cid):
|
||
return False
|
||
# §B.2 union, scope-role grants first — these bypass per-RFC curation (a
|
||
# collection/project/global Owner or RFC Contributor ⊇ discussant).
|
||
if effective_scope_role(user, cid) is not None:
|
||
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
|
||
# grandfathered implicit-public baseline (curation preserved): on the default
|
||
# collection a granted deployment contributor 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_collection_write_baseline(user, cid):
|
||
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
|
||
cid = collection_of_rfc(rfc_slug)
|
||
if not can_read_collection(user, cid):
|
||
return False
|
||
# §B.2 union, scope-role grants first (a collection/project/global RFC
|
||
# Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all).
|
||
if effective_scope_role(user, cid) is not None:
|
||
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
|
||
# grandfathered implicit-public baseline (curation preserved): until an owner
|
||
# exists, a granted deployment contributor on the public default collection
|
||
# may contribute.
|
||
if not owners and _has_collection_write_baseline(user, cid):
|
||
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
|
||
cid = collection_of_rfc(rfc_slug)
|
||
if not can_read_collection(user, cid):
|
||
return False
|
||
# An Owner reaching the collection (collection/project/global Owner, or a
|
||
# deployment owner/admin) may invite; otherwise only the RFC's frontmatter
|
||
# owner. Per-RFC collaborators and the baseline do not get the invite power.
|
||
if is_collection_superuser(user, cid):
|
||
return True
|
||
return is_rfc_owner(user, rfc_slug)
|
||
|
||
|
||
def new_state() -> str:
|
||
return secrets.token_urlsafe(16)
|