File indexing completed on 2026-09-28 09:37:31
0001 """Organization membership, observed at sign-in and recorded upstream.
0002
0003 Two things decide whether an account may act on the production system, and
0004 they are kept apart because they have different owners:
0005
0006 eic unset | true | false observed; written only by this module
0007 rights unset | read | basic | ops granted; written only by a person
0008
0009 An account may act when `rights` is not `read` and either `eic` is true or
0010 `rights` grants it (`basic` or `ops`). Reading monitoring information needs
0011 only a signed-in account, at every level.
0012
0013 Provenance is carried by which field a value sits in rather than by a marker
0014 recording who wrote it. This module maintains `eic` and nothing else, so a
0015 grant made by a person is never undone by a later sign-in; a person maintains
0016 `rights` and nothing else, so someone who leaves the organization loses access
0017 at their next sign-in without anyone acting. `rights: read` is an explicit
0018 refusal that outranks membership.
0019
0020 Both fields live on the account in swf-monitor, beside the identity that
0021 component already establishes from `X-Remote-User`, and enforcement is there
0022 too — so a person reaching swf-monitor directly inside the perimeter is judged
0023 by the same rule as one arriving through this proxy. swf-remote is where
0024 GitHub sign-in happens, which is the only reason membership is observed here
0025 rather than there.
0026
0027 A check that cannot reach an answer writes nothing. Recording a negative on a
0028 network failure or a revoked token would strip access from an account that
0029 still holds it, so an indeterminate result leaves the stored value alone.
0030
0031 See docs/live-data-access.md.
0032 """
0033 from __future__ import annotations
0034
0035 import logging
0036
0037 import httpx
0038 from allauth.account.signals import user_logged_in
0039 from django.conf import settings
0040 from django.dispatch import receiver
0041
0042 from . import monitor_client
0043
0044 logger = logging.getLogger(__name__)
0045
0046 GITHUB_API = 'https://api.github.com'
0047 TIMEOUT = 15
0048
0049
0050
0051
0052
0053 AUTHORITY_PATH = '/api/user-authority/'
0054 RIGHTS_PATH = '/api/user-rights/'
0055
0056
0057
0058
0059
0060 SERVICE_USER = 'swf-remote-authority'
0061
0062 RIGHTS_VALUES = ('read', 'basic', 'ops')
0063
0064
0065 def github_token(user) -> str | None:
0066 """The user's stored GitHub OAuth token, or None if they have no link."""
0067 try:
0068 from allauth.socialaccount.models import SocialToken
0069 token = (SocialToken.objects
0070 .filter(account__user=user, account__provider='github')
0071 .order_by('-id')
0072 .first())
0073 return token.token if token else None
0074 except Exception as e:
0075 logger.error(f"authority: reading GitHub token for {user}: {e}")
0076 return None
0077
0078
0079 def github_login(user) -> str | None:
0080 """The user's GitHub login name, or None if they have no link."""
0081 try:
0082 from allauth.socialaccount.models import SocialAccount
0083 account = (SocialAccount.objects
0084 .filter(user=user, provider='github')
0085 .order_by('-id')
0086 .first())
0087 return (account.extra_data or {}).get('login') if account else None
0088 except Exception as e:
0089 logger.error(f"authority: reading GitHub account for {user}: {e}")
0090 return None
0091
0092
0093
0094 ORG_READ_SCOPES = {'read:org', 'write:org', 'admin:org'}
0095
0096
0097 def check_membership(token: str, org: str | None = None) -> tuple[bool | None, str]:
0098 """Whether the token's owner belongs to the organization, and if not
0099 established, why.
0100
0101 Returns (True|False, '') on a definite answer and (None, reason) when the
0102 answer could not be established. Uses the caller's own token against
0103 /user/memberships, so a private membership — GitHub's default — is
0104 visible, but only to a token carrying read:org. Without it a private
0105 member reads as absent, so a 404 is believed only from a token whose
0106 X-OAuth-Scopes include an org-read scope: every token issued before the
0107 scope was requested would otherwise record members as non-members.
0108 """
0109 org = org or settings.EIC_ORG
0110 url = f'{GITHUB_API}/user/memberships/orgs/{org}'
0111 try:
0112 resp = httpx.get(url, timeout=TIMEOUT, headers={
0113 'Authorization': f'Bearer {token}',
0114 'Accept': 'application/vnd.github+json',
0115 })
0116 except Exception as e:
0117 logger.error(f"authority: GitHub membership check failed: {e}")
0118 return None, f'GitHub could not be reached ({type(e).__name__})'
0119
0120 scopes = {s.strip() for s in resp.headers.get('X-OAuth-Scopes', '').split(',')
0121 if s.strip()}
0122 if resp.status_code in (200, 404) and not scopes & ORG_READ_SCOPES:
0123 return None, ('the GitHub token lacks the read:org scope, so a private '
0124 'membership cannot be seen; sign in again to grant it')
0125 if resp.status_code == 200:
0126 state = (resp.json() or {}).get('state')
0127 if state == 'active':
0128 return True, ''
0129
0130 logger.info(f"authority: membership state '{state}' for org {org}")
0131 return False, ''
0132 if resp.status_code == 404:
0133 return False, ''
0134 if resp.status_code in (401, 403):
0135 detail = ''
0136 try:
0137 detail = str((resp.json() or {}).get('message', ''))[:200]
0138 except Exception:
0139 pass
0140 return None, (f'GitHub refused the membership check ({resp.status_code}'
0141 f'{": " + detail if detail else ""}); the token may be '
0142 f'revoked, or the {org} organization has not approved '
0143 f'this application')
0144 return None, f'GitHub answered the membership check with {resp.status_code}'
0145
0146
0147 def resolve_membership(token: str, org: str | None = None) -> bool | None:
0148 """check_membership without the reason."""
0149 return check_membership(token, org)[0]
0150
0151
0152 def _save_status(username: str, **fields) -> None:
0153 """Keep the account's latest check where the account menu reads it."""
0154 from django.contrib.auth.models import User
0155 from .models import AuthorityStatus
0156
0157 user = User.objects.filter(username=username).first()
0158 if user is None:
0159 return
0160 AuthorityStatus.objects.update_or_create(user=user, defaults=fields)
0161
0162
0163 def record_membership(username: str, eic: bool | None, github: str = '') -> bool:
0164 """Record observed organization membership on the account in swf-monitor.
0165
0166 This endpoint refuses `rights`, so no fault in the sign-in path can reach
0167 a grant. `eic=None` clears the observation. Only a write swf-monitor
0168 accepted counts; the account menu's copy is updated after it.
0169 """
0170 if not username:
0171 return False
0172 attributes: dict = {'eic': eic}
0173 if github:
0174 attributes['github'] = github
0175 result = monitor_client._post(
0176 AUTHORITY_PATH,
0177 {'username': username, 'authority': attributes},
0178 as_user=SERVICE_USER,
0179 )
0180 if not isinstance(result, dict) or result.get('error') \
0181 or not isinstance(result.get('authority'), dict):
0182 logger.error(f"authority: recording {attributes} for {username} failed: "
0183 f"{(result or {}).get('error') if isinstance(result, dict) else result!r}")
0184 return False
0185 from django.utils import timezone
0186 _save_status(username, github=github, eic=eic, checked_at=timezone.now(),
0187 failed='', failed_at=None)
0188 logger.info(f"authority: recorded {attributes} for {username}")
0189 return True
0190
0191
0192 def report_failure(username: str, reason: str, github: str = '') -> None:
0193 """A GitHub sign-in whose check reached no answer: surface it everywhere.
0194
0195 The account menu shows it (local copy), swf-monitor records it for the
0196 authority_check alarm and the User admin page, and the log carries it at
0197 ERROR. The stored `eic` is left alone upstream: a check without an answer
0198 is not an observation.
0199 """
0200 from django.utils import timezone
0201 logger.error(f"authority: membership check for {username} ({github or 'no login'}) "
0202 f"reached no answer: {reason}")
0203 _save_status(username, github=github, failed=reason, failed_at=timezone.now())
0204 attributes: dict = {'check_failed': reason}
0205 if github:
0206 attributes['github'] = github
0207 result = monitor_client._post(
0208 AUTHORITY_PATH,
0209 {'username': username, 'authority': attributes},
0210 as_user=SERVICE_USER,
0211 )
0212 if not isinstance(result, dict) or result.get('error'):
0213
0214 logger.error(f"authority: reporting the failed check for {username} "
0215 f"upstream failed too: {result!r}")
0216
0217
0218 def record_rights(username: str, rights: str | None) -> bool:
0219 """Grant or clear rights on the account in swf-monitor.
0220
0221 A person's decision, never the sweep's: the sign-in path does not call
0222 this, and the endpoint it posts to refuses `eic`. `rights=None` clears the
0223 grant.
0224 """
0225 if not username:
0226 return False
0227 if rights is not None and rights not in RIGHTS_VALUES:
0228 logger.error(f"authority: refusing unknown rights {rights!r} for {username}")
0229 return False
0230 result = monitor_client._post(
0231 RIGHTS_PATH,
0232 {'username': username, 'rights': rights},
0233 as_user=SERVICE_USER,
0234 )
0235 if isinstance(result, dict) and result.get('error'):
0236 logger.error(f"authority: granting rights {rights!r} to {username} "
0237 f"failed: {result['error']}")
0238 return False
0239 logger.info(f"authority: granted rights {rights!r} to {username}")
0240 return True
0241
0242
0243 def refresh_for(user) -> bool | None:
0244 """Observe one signed-in user's membership and record it upstream.
0245
0246 Writes `eic` and the GitHub login the check was made against, so the
0247 account pages can show which identity was tested — several accounts carry
0248 a Django username unlike their GitHub login. Never writes `rights`.
0249
0250 Every GitHub-linked account ends in one of two outcomes: a membership
0251 write swf-monitor accepted, or a reported failure (report_failure). From
0252 the 9/9 backfill to 9/25 every sign-in took a third, silent path — no
0253 token was stored, and this function returned without a word.
0254
0255 Returns the membership observed, or None when nothing was observed.
0256 """
0257 login = github_login(user) or ''
0258 token = github_token(user)
0259 if not token:
0260 if login:
0261 report_failure(user.username, 'no stored GitHub token for the '
0262 'account, so membership could not be asked', login)
0263
0264
0265
0266 return None
0267 member, reason = check_membership(token)
0268 if member is None:
0269 report_failure(user.username, reason, login)
0270 return None
0271 if not record_membership(user.username, member, login):
0272 report_failure(user.username, 'swf-monitor did not accept the '
0273 'membership write', login)
0274 return None
0275 return member
0276
0277
0278 @receiver(user_logged_in)
0279 def refresh_on_login(sender, request, user, **kwargs):
0280 """Re-observe the account's membership on every sign-in."""
0281 try:
0282 refresh_for(user)
0283 except Exception as e:
0284
0285
0286
0287 logger.error(f"authority: refresh on login for {user} failed: {e}")
0288 try:
0289 report_failure(user.username, f'the check raised {type(e).__name__}',
0290 github_login(user) or '')
0291 except Exception as inner:
0292 logger.error(f"authority: reporting that failure raised too: {inner}")