Gate membership on email (via admin personal token) instead of slug — fixes guest contributors
This commit is contained in:
1 parent
f55008f46c
commit
b87a057354
1 file changed
+50
-17
+50
-17
@@ -65,6 +65,13 @@ OC_TOKEN_URL = "https://opencollective.com/oauth/token"
|
|||||||
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
|
OC_GRAPHQL_URL = "https://opencollective.com/api/graphql/v2"
|
||||||
OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative")
|
OC_COLLECTIVE_SLUG = os.environ.get("OC_COLLECTIVE_SLUG", "inference-cooperative")
|
||||||
|
|
||||||
|
# Personal token for the "Inference Co-op Bot" account, which is an admin of
|
||||||
|
# the collective. Authenticated as an admin, the GraphQL API exposes member
|
||||||
|
# emails (which are hidden from anonymous access). We use this to match a
|
||||||
|
# member by email — the stable identifier that works even for guest
|
||||||
|
# contributors (who have no Open Collective account and thus no usable slug).
|
||||||
|
OC_PERSONAL_TOKEN = os.environ.get("OC_PERSONAL_TOKEN", "")
|
||||||
|
|
||||||
# Monthly credit budget (in USD of tokens) for all members.
|
# Monthly credit budget (in USD of tokens) for all members.
|
||||||
# Single sliding-scale tier: everyone gets the same $15/month in credits,
|
# Single sliding-scale tier: everyone gets the same $15/month in credits,
|
||||||
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
|
# regardless of their $10/15/20 contribution. Governance decision (Loomio).
|
||||||
@@ -156,29 +163,54 @@ def is_pending_member(slug: str) -> bool:
|
|||||||
return row is not None
|
return row is not None
|
||||||
|
|
||||||
|
|
||||||
async def is_active_member(slug: str) -> bool:
|
async def fetch_member_emails() -> dict[str, str]:
|
||||||
"""Check the LIVE Open Collective membership list (authoritative source of
|
"""Return a map of email -> role for active financial contributors.
|
||||||
truth) for whether this slug is a financial contributor (BACKER/ADMIN),
|
|
||||||
not just the fiscal host. More reliable than the webhook, which only fires
|
Uses the bot's personal token (admin) so the GraphQL API exposes member
|
||||||
on new events and can miss existing members."""
|
emails, which are hidden from anonymous access. This is the authoritative
|
||||||
if not slug:
|
source of truth for "who is a member" — and it works for guest
|
||||||
return False
|
contributors (who have no OC account) because their email is still in the
|
||||||
|
list.
|
||||||
|
"""
|
||||||
|
if not OC_PERSONAL_TOKEN:
|
||||||
|
logger.warning("OC_PERSONAL_TOKEN not set; cannot fetch member emails")
|
||||||
|
return {}
|
||||||
query = (
|
query = (
|
||||||
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { slug } } } } }'
|
'{ collective(slug: "%s") { members(limit: 100) { nodes { role account { email } } } } }'
|
||||||
% OC_COLLECTIVE_SLUG
|
% OC_COLLECTIVE_SLUG
|
||||||
)
|
)
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
r = await client.post(OC_GRAPHQL_URL, json={"query": query})
|
r = await client.post(
|
||||||
|
OC_GRAPHQL_URL,
|
||||||
|
headers={"Personal-Token": OC_PERSONAL_TOKEN},
|
||||||
|
json={"query": query},
|
||||||
|
)
|
||||||
if r.status_code != 200:
|
if r.status_code != 200:
|
||||||
logger.warning("OC membership check failed: %s", r.status_code)
|
logger.warning("OC member fetch failed: %s", r.status_code)
|
||||||
return False
|
return {}
|
||||||
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
|
nodes = r.json().get("data", {}).get("collective", {}).get("members", {}).get("nodes", [])
|
||||||
|
result: dict[str, str] = {}
|
||||||
for n in nodes:
|
for n in nodes:
|
||||||
role = n.get("role", "")
|
role = n.get("role", "")
|
||||||
acct = n.get("account", {}) or {}
|
acct = n.get("account", {}) or {}
|
||||||
if acct.get("slug") == slug and role in ("BACKER", "ADMIN"):
|
email = (acct.get("email") or "").strip().lower()
|
||||||
return True
|
if email and role in ("BACKER", "ADMIN"):
|
||||||
return False
|
result[email] = role
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def is_active_member(email: str) -> bool:
|
||||||
|
"""Check whether this email is an active financial contributor.
|
||||||
|
|
||||||
|
Matches on email (not slug) so guest contributors — who have no OC account
|
||||||
|
and thus no usable slug — are still recognized. The email comes from the
|
||||||
|
OAuth `me` query (with the user's consent); we match it against the
|
||||||
|
admin-visible member list.
|
||||||
|
"""
|
||||||
|
if not email:
|
||||||
|
return False
|
||||||
|
members = await fetch_member_emails()
|
||||||
|
return email.strip().lower() in members
|
||||||
|
|
||||||
|
|
||||||
async def get_active_member_emails() -> list[str]:
|
async def get_active_member_emails() -> list[str]:
|
||||||
@@ -667,9 +699,10 @@ async def oauth_callback(request: Request):
|
|||||||
|
|
||||||
# Gate: only provision if this person is an active financial contributor.
|
# Gate: only provision if this person is an active financial contributor.
|
||||||
# We check the LIVE Open Collective membership list (authoritative), not
|
# We check the LIVE Open Collective membership list (authoritative), not
|
||||||
# the webhook's pending table (which only fires on new events).
|
# the webhook's pending table (which only fires on new events). Matching is
|
||||||
if not await is_active_member(slug):
|
# by email so guest contributors (no OC account) are still recognized.
|
||||||
logger.warning("Rejected non-member %s (slug=%s not an active contributor)", email, slug)
|
if not await is_active_member(email):
|
||||||
|
logger.warning("Rejected non-member %s (email not an active contributor)", email)
|
||||||
html = """<!DOCTYPE html>
|
html = """<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
|||||||
Reference in new issue
Block a user