55a8be051a
Second §19.2 settlement after v1. New §6.7 alongside §6.6: optional `funder:` frontmatter field names a single gitea_login; a `funder_consents` app-db row records funder-side opt-in; both halves required for the binding to activate (two-key rule). Funder universe replaces — does not augment — the operator universe per-RFC for attribution-clean resolution. Funder role grants zero §6.1/§6.3 authority. Three revocation paths each restore the operator-credentials status quo. §19.2's credential-delegation entry is split: lighter half marked settled with a pointer to §6.7; operational-realities half (mid-call failure, rotation, billing, rate-limit attribution) lives on as its own entry. Test suite is 125/125 green (106 prior + 19 new). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
245 lines
8.9 KiB
Python
245 lines
8.9 KiB
Python
"""§6.7: per-RFC credential delegation — the funder role.
|
|
|
|
Three responsibilities live here:
|
|
|
|
1. The runtime resolver. Given a (slug, picker_key) tuple, return the
|
|
`BaseProvider` instance that should serve calls. When no consenting
|
|
funder is in effect, this is the operator's pre-built provider.
|
|
When a consenting funder is named on the RFC and has registered
|
|
credentials for the picker_key's provider family, a fresh provider
|
|
instance is constructed with the funder's credentials.
|
|
|
|
2. The universe resolver. `resolve_funder_universe(slug, operator)`
|
|
returns the funder's per-RFC universe — the subset of operator-
|
|
enabled picker keys served by provider families the funder has
|
|
registered. Returns None when no funder is in effect; an empty
|
|
list when a consenting funder has no registrations intersecting
|
|
the operator's enabled set.
|
|
|
|
3. The consent + credential CRUD. Small helpers that the §17
|
|
`/api/users/me/funder` endpoints in api.py call into.
|
|
|
|
The lighter half (this module) deliberately does NOT handle:
|
|
|
|
- Mid-call failure with fallback. Per §6.7, the resolution is
|
|
attribution-clean: a consenting-funder RFC is paid entirely from
|
|
funder credentials. If construction fails or the call fails,
|
|
that's a failure — no silent fallback to operator credentials.
|
|
- Per-instance caching. Constructs fresh on every call. The cost is
|
|
real (HTTP client setup) but bounded; caching across calls is
|
|
deferred until evidence shows the cost bites.
|
|
- Billing attribution surface, rotation ceremony, rate-limit
|
|
attribution. All deferred to the operational-realities §19.2
|
|
candidate.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from . import db, providers as providers_mod
|
|
from .providers import BaseProvider
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Universe + provider resolution
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def resolve_funder_universe(
|
|
slug: str, operator_providers: dict[str, BaseProvider]
|
|
) -> list[str] | None:
|
|
"""The funder's registered universe for this RFC, or None.
|
|
|
|
None means no consenting funder is in effect — caller falls back to
|
|
the operator universe per §6.7. A list (possibly empty) means a
|
|
consenting funder is in effect; the list is the subset of operator-
|
|
enabled picker keys for which the funder has supplied a provider-
|
|
family API key. An empty list means the named-and-consenting
|
|
funder's registered providers don't intersect the operator's
|
|
enabled set — the RFC drops into the §6.6 opt-out shape.
|
|
"""
|
|
funder_user_id = consenting_funder_user_id(slug)
|
|
if funder_user_id is None:
|
|
return None
|
|
registered = registered_provider_families(funder_user_id)
|
|
return [
|
|
picker_key
|
|
for picker_key in operator_providers
|
|
if (family := providers_mod.provider_family_for_picker_key(picker_key))
|
|
and family in registered
|
|
]
|
|
|
|
|
|
def provider_for_rfc(
|
|
slug: str,
|
|
picker_key: str,
|
|
operator_providers: dict[str, BaseProvider],
|
|
) -> BaseProvider | None:
|
|
"""The `BaseProvider` that should serve calls for (slug, picker_key).
|
|
|
|
When a consenting funder is named on the RFC and has registered
|
|
credentials for picker_key's provider family, returns a fresh
|
|
funder-credentials provider instance. Otherwise returns the
|
|
operator's pre-built provider. Returns None when picker_key isn't
|
|
available in the resolved universe — caller refuses per §6.6/§6.7.
|
|
"""
|
|
funder_user_id = consenting_funder_user_id(slug)
|
|
if funder_user_id is None:
|
|
return operator_providers.get(picker_key)
|
|
family = providers_mod.provider_family_for_picker_key(picker_key)
|
|
if family is None:
|
|
return None
|
|
row = db.conn().execute(
|
|
"SELECT api_key FROM user_funder_credentials WHERE user_id = ? AND provider = ?",
|
|
(funder_user_id, family),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
return providers_mod.construct_for_funder(picker_key, row["api_key"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Consenting-funder lookup (the two-key §6.7 rule)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def consenting_funder_user_id(slug: str) -> int | None:
|
|
"""Resolve the consenting funder for an RFC, or None.
|
|
|
|
The two-key rule per §6.7: `cached_rfcs.funder_login` names a user
|
|
AND a `funder_consents` row exists for the (user, slug) pair. The
|
|
third revocation path (account-disabled) is a structural commitment
|
|
— when the disable affordance lands per §6.1, it joins this check
|
|
as a third AND clause; until then, the two-key rule stands alone.
|
|
"""
|
|
row = db.conn().execute(
|
|
"""
|
|
SELECT u.id AS user_id
|
|
FROM cached_rfcs r
|
|
JOIN users u ON u.gitea_login = r.funder_login
|
|
WHERE r.slug = ? AND r.funder_login IS NOT NULL
|
|
""",
|
|
(slug,),
|
|
).fetchone()
|
|
if not row:
|
|
return None
|
|
consent = db.conn().execute(
|
|
"SELECT 1 FROM funder_consents WHERE user_id = ? AND rfc_slug = ?",
|
|
(row["user_id"], slug),
|
|
).fetchone()
|
|
if not consent:
|
|
return None
|
|
return row["user_id"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Credential + consent CRUD (called by the §17 funder endpoints)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def registered_provider_families(user_id: int) -> set[str]:
|
|
"""The provider families this user has registered credentials for."""
|
|
rows = db.conn().execute(
|
|
"SELECT provider FROM user_funder_credentials WHERE user_id = ?",
|
|
(user_id,),
|
|
).fetchall()
|
|
return {r["provider"] for r in rows}
|
|
|
|
|
|
def list_credentials(user_id: int) -> list[dict[str, str]]:
|
|
"""Per-provider registration metadata for the funder surface.
|
|
|
|
The API key itself is never returned to the client. The list shape
|
|
is one row per registered family, with the registration timestamp
|
|
for the settings page's display.
|
|
"""
|
|
rows = db.conn().execute(
|
|
"""
|
|
SELECT provider, created_at, updated_at
|
|
FROM user_funder_credentials
|
|
WHERE user_id = ?
|
|
ORDER BY provider
|
|
""",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [
|
|
{"provider": r["provider"], "created_at": r["created_at"], "updated_at": r["updated_at"]}
|
|
for r in rows
|
|
]
|
|
|
|
|
|
def upsert_credential(user_id: int, provider: str, api_key: str) -> None:
|
|
"""Register or replace an API key for a provider family.
|
|
|
|
Idempotent. Callers gate the provider against
|
|
`providers_mod.FUNDER_PROVIDER_FAMILIES` and against the operator's
|
|
enabled set (per §6.7, the funder cannot expand the operator
|
|
universe) before calling here.
|
|
"""
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO user_funder_credentials (user_id, provider, api_key)
|
|
VALUES (?, ?, ?)
|
|
ON CONFLICT(user_id, provider) DO UPDATE SET
|
|
api_key = excluded.api_key,
|
|
updated_at = datetime('now')
|
|
""",
|
|
(user_id, provider, api_key),
|
|
)
|
|
|
|
|
|
def delete_credential(user_id: int, provider: str) -> bool:
|
|
"""Remove a registered key. Returns True if a row was deleted.
|
|
|
|
Existing `funder_consents` rows are intentionally left in place —
|
|
the resolved funder universe for affected RFCs simply shrinks. If
|
|
the funder later re-registers the family, those RFCs resume
|
|
drawing on the funder's universe without re-consenting.
|
|
"""
|
|
cur = db.conn().execute(
|
|
"DELETE FROM user_funder_credentials WHERE user_id = ? AND provider = ?",
|
|
(user_id, provider),
|
|
)
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def list_consents(user_id: int) -> list[str]:
|
|
"""The RFC slugs this user has consented to fund. Sorted for stable display."""
|
|
rows = db.conn().execute(
|
|
"SELECT rfc_slug FROM funder_consents WHERE user_id = ? ORDER BY rfc_slug",
|
|
(user_id,),
|
|
).fetchall()
|
|
return [r["rfc_slug"] for r in rows]
|
|
|
|
|
|
def add_consent(user_id: int, slug: str) -> None:
|
|
"""Idempotent funder-side opt-in to fund this RFC.
|
|
|
|
Callers gate with `has_any_credentials` so a consent without any
|
|
registered universe (which would be inert) is refused at the API
|
|
layer rather than written.
|
|
"""
|
|
db.conn().execute(
|
|
"""
|
|
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
|
|
ON CONFLICT(user_id, rfc_slug) DO NOTHING
|
|
""",
|
|
(user_id, slug),
|
|
)
|
|
|
|
|
|
def remove_consent(user_id: int, slug: str) -> bool:
|
|
"""Withdraw consent per §6.7's first revocation path. Returns True
|
|
if a row was removed."""
|
|
cur = db.conn().execute(
|
|
"DELETE FROM funder_consents WHERE user_id = ? AND rfc_slug = ?",
|
|
(user_id, slug),
|
|
)
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def has_any_credentials(user_id: int) -> bool:
|
|
row = db.conn().execute(
|
|
"SELECT 1 FROM user_funder_credentials WHERE user_id = ? LIMIT 1",
|
|
(user_id,),
|
|
).fetchone()
|
|
return row is not None
|