Back to home page

EIC code displayed by LXR

 
 

    


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 # swf-monitor exposes membership and rights as separate endpoints, so the
0050 # path a sign-in travels cannot reach `rights` at all — the separation is
0051 # enforced by URL rather than by inspecting a request body in a handler both
0052 # callers share.
0053 AUTHORITY_PATH = '/api/user-authority/'
0054 RIGHTS_PATH = '/api/user-rights/'
0055 
0056 # Identity presented to swf-monitor for the authority write. It is deliberately
0057 # distinct from the general sync identity and from any person: the endpoint
0058 # writes privilege, so it refuses a caller wearing someone's name. The value is
0059 # reserved in ACCOUNT_USERNAME_BLACKLIST so no GitHub login can become it.
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 # Scopes under which /user/memberships can see a private membership.
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         # 'pending' is an unaccepted invitation — not yet a member.
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         # The monitor's alarm still catches an account with no record at all.
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         # Otherwise a local account, with no GitHub identity to observe. Its
0264         # access was established inside the BNL perimeter and is carried by
0265         # `rights`.
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         # Sign-in must not fail because the check did: the account keeps
0285         # whatever it already holds, and the failure is reported like any
0286         # other check that reached no answer.
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}")