Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-10-20 10:15:24

0001 """Authority — who may act on the system, as distinct from who may watch it.
0002 
0003 Reading swf-monitor needs a signed-in account and nothing more, at every
0004 level, so collaborators outside the `eic` GitHub organisation keep their
0005 monitoring view. Acting on the system needs authority, held per account as
0006 ``UserPreference.prefs['authority']`` in two fields:
0007 
0008 ``eic``     unset | true | false        written only by swf-remote's sign-in sweep
0009 ``rights``  unset | read | basic | ops  written only by a person, in the User admin page
0010 
0011     may act  =  rights != 'read'  and  (eic is True or rights in ('basic', 'ops'))
0012 
0013 Provenance is carried by which field a value sits in, not by a set-by marker.
0014 The sweep maintains ``eic`` and nothing else, so an administrative grant
0015 cannot be undone by a later sign-in; a person maintains ``rights`` and nothing
0016 else, so a member who leaves the organisation loses authority at their next
0017 sign-in with no one acting. ``rights: read`` is an explicit veto that outranks
0018 ``eic`` — the way to stop a member of the organisation from acting.
0019 ``rights: unset`` is the ordinary state of a member, who draws authority from
0020 ``eic`` alone.
0021 
0022 Each level names what the person may do rather than what they may not,
0023 because they read it on their own account page: ``read``, not "none". Both
0024 fields default to unset, and an account with no record resolves to unable to
0025 act, so a capability nobody declared is refused rather than exposed.
0026 
0027 Model and rollout: docs/AUTHORITY.md.
0028 """
0029 
0030 from django.db import transaction
0031 
0032 AUTHORITY_KEY = 'authority'
0033 
0034 #: Rights levels, least to most. Unset is the absence of the field, and is
0035 #: not a level: an ordinary member holds no rights and acts on ``eic`` alone.
0036 RIGHTS_READ = 'read'
0037 RIGHTS_BASIC = 'basic'
0038 RIGHTS_OPS = 'ops'
0039 RIGHTS_VALUES = (RIGHTS_READ, RIGHTS_BASIC, RIGHTS_OPS)
0040 
0041 #: Rights that confer authority on their own, without organisation membership.
0042 RIGHTS_GRANTING = (RIGHTS_BASIC, RIGHTS_OPS)
0043 
0044 #: Roles: flags beside the rights level, held together or apart. Set by a
0045 #: person on the User admin page, never by the sign-in sweep.
0046 #:
0047 #: PAC, the physics analysis coordinator: priority setting on requests
0048 #: needs it, or the operations role (the physics coordinator's
0049 #: requirement, 2026-09-10: coordinators only, since anyone may otherwise
0050 #: raise their own request in good faith).
0051 PAC_KEY = 'pac'
0052 PAC_MEANING = ('Set request priorities, as a physics analysis coordinator '
0053                '(PAC).')
0054 #: Operations: administers accounts on the User admin page and sets
0055 #: priorities. Confers authority to act on its own. The role replaces the
0056 #: former ``rights: ops`` rung, which is still read as the role.
0057 OPS_KEY = 'ops'
0058 OPS_MEANING = ('Act on the production system, administer accounts, and set '
0059                'request priorities, as operations.')
0060 ROLE_KEYS = (PAC_KEY, OPS_KEY)
0061 
0062 #: What a person may do at each level, as they read it on their account page.
0063 RIGHTS_MEANING = {
0064     RIGHTS_READ: 'Read monitoring information. Actions are withheld.',
0065     RIGHTS_BASIC: 'Act on the production system.',
0066     RIGHTS_OPS: 'Act on the production system, and administer accounts.',
0067 }
0068 
0069 #: Tunnel identity swf-remote presents when it writes the observed field. It
0070 #: is a service name, never a person, so a request carrying a signed-in
0071 #: person's identity cannot write authority even if the endpoint is ever
0072 #: reachable through the proxy.
0073 AUTHORITY_WRITER = 'swf-remote-authority'
0074 
0075 
0076 class AuthorityError(ValueError):
0077     """Malformed authority input."""
0078 
0079 
0080 def empty_authority():
0081     """The record an account with nothing written resolves to."""
0082     return {'eic': None, 'rights': None, 'github': '', 'eic_at': '',
0083             'check_failed': '', 'check_failed_at': '',
0084             'pac': False, 'ops': False}
0085 
0086 
0087 def _record_from(stored):
0088     record = empty_authority()
0089     if not isinstance(stored, dict):
0090         return record
0091     eic = stored.get('eic')
0092     if isinstance(eic, bool):
0093         record['eic'] = eic
0094     rights = stored.get('rights')
0095     if rights in RIGHTS_VALUES:
0096         record['rights'] = rights
0097     record['pac'] = stored.get(PAC_KEY) is True
0098     # The former rights rung reads as the role, so a record written before
0099     # the roles existed keeps what it had.
0100     record['ops'] = stored.get(OPS_KEY) is True or rights == RIGHTS_OPS
0101     for field in ('github', 'eic_at', 'check_failed', 'check_failed_at'):
0102         value = stored.get(field)
0103         if isinstance(value, str):
0104             record[field] = value
0105     return record
0106 
0107 
0108 def get_authority(username):
0109     """The authority record for ``username``; unset fields read as None."""
0110     from .models import UserPreference
0111 
0112     if not username:
0113         return empty_authority()
0114     row = UserPreference.objects.filter(username=username).first()
0115     return _record_from((row.prefs or {}).get(AUTHORITY_KEY) if row else None)
0116 
0117 
0118 def all_authority():
0119     """``{username: record}`` for every account carrying an authority record.
0120 
0121     An account absent from the map has nothing written: both fields unset,
0122     and unable to act until one of them is.
0123     """
0124     from .models import UserPreference
0125 
0126     out = {}
0127     for username, prefs in UserPreference.objects.values_list(
0128             'username', 'prefs'):
0129         stored = (prefs or {}).get(AUTHORITY_KEY)
0130         if isinstance(stored, dict):
0131             out[username] = _record_from(stored)
0132     return out
0133 
0134 
0135 def may_act(record):
0136     """Whether an authority record confers authority to act.
0137 
0138     Takes a record from ``get_authority`` or a username.
0139     """
0140     if not isinstance(record, dict):
0141         record = get_authority(record)
0142     if record.get('rights') == RIGHTS_READ:
0143         return False
0144     return (record.get('eic') is True or record.get('rights') in RIGHTS_GRANTING
0145             or record.get('ops') is True)
0146 
0147 
0148 def is_ops(record):
0149     """Whether a record carries the operations role."""
0150     if not isinstance(record, dict):
0151         record = get_authority(record)
0152     return record.get('ops') is True
0153 
0154 
0155 def is_pac(record):
0156     """Whether a record carries the physics analysis coordinator role."""
0157     if not isinstance(record, dict):
0158         record = get_authority(record)
0159     return record.get('pac') is True
0160 
0161 
0162 def may_set_priority(record):
0163     """Whether an account may set request priorities: it may act, and it
0164     is a physics analysis coordinator or holds operations rights."""
0165     if not isinstance(record, dict):
0166         record = get_authority(record)
0167     return may_act(record) and (is_pac(record) or is_ops(record))
0168 
0169 
0170 PRIORITY_REFUSAL = ('Request priorities are set by the physics analysis '
0171                     'coordinators (PAC). Your account does not hold that '
0172                     'role; a coordinator or an administrator can grant it '
0173                     'on the User admin page.')
0174 
0175 
0176 #: The SysConfig knob that turns refusal on. Until it is true the gates
0177 #: observe: a refusal they would have made is logged and the request goes
0178 #: through, so the rule is proven against live traffic before it bites.
0179 ENFORCE_KEY = 'authority_enforce'
0180 
0181 JOIN_URL = 'https://eic.github.io/documentation/getstarted.html'
0182 
0183 
0184 def enforcing():
0185     """Whether the gates refuse, or only observe."""
0186     from .models import SysConfig
0187 
0188     return bool(SysConfig.get_setting(ENFORCE_KEY, False))
0189 
0190 
0191 def refusal_text(username):
0192     """What a person who may not act is told, with the way forward.
0193 
0194     GitHub has no self-service join, so the text names the login that was
0195     checked and links the procedure rather than leaving a bare refusal.
0196     """
0197     record = get_authority(username)
0198     login = record['github'] or username
0199     if record['rights'] == RIGHTS_READ:
0200         return ('Your account is set to read on this system: you may read '
0201                 'monitoring information, and actions against the production '
0202                 'system are withheld. An administrator can change that on '
0203                 'the User admin page.')
0204     if record['eic'] is False:
0205         return (f'Your GitHub account {login} is not a member of the eic '
0206                 'organization. ePIC production monitoring requires membership '
0207                 'for actions against the production system; reading '
0208                 'monitoring information does not. The joining procedure is on '
0209                 f'the ePIC Software & Computing Get Started page, {JOIN_URL} '
0210                 '(see "Join GitHub"). Once you have been added, sign in again '
0211                 'and your account will work.')
0212     return (f'No eic organization membership has been recorded for {login}. '
0213             'Membership is checked when you sign in through '
0214             'epic-devcloud.org; reading monitoring information needs no '
0215             'membership, actions against the production system do. The '
0216             'joining procedure is on the ePIC Software & Computing Get '
0217             f'Started page, {JOIN_URL} (see "Join GitHub").')
0218 
0219 
0220 def _write(username, changes):
0221     from .models import UserPreference
0222 
0223     if not username:
0224         raise AuthorityError('username is required')
0225     with transaction.atomic():
0226         row, _ = UserPreference.objects.select_for_update().get_or_create(
0227             username=username, defaults={'prefs': {}})
0228         prefs = dict(row.prefs or {})
0229         stored = prefs.get(AUTHORITY_KEY)
0230         record = dict(stored) if isinstance(stored, dict) else {}
0231         for field, value in changes.items():
0232             if value is None:
0233                 record.pop(field, None)
0234             else:
0235                 record[field] = value
0236         prefs[AUTHORITY_KEY] = record
0237         row.prefs = prefs
0238         row.save(update_fields=['prefs', 'updated_at'])
0239     return _record_from(prefs[AUTHORITY_KEY])
0240 
0241 
0242 def set_eic(username, eic, github=None):
0243     """Record observed `eic` organisation membership. The sweep's setter.
0244 
0245     Writes the observed field and the GitHub login it was observed for, and
0246     nothing else: a sign-in cannot alter granted rights. ``eic=None`` clears
0247     the field back to unset.
0248 
0249     The account need not exist: the record is keyed by username string, so an
0250     account created later already carries what was written for the name.
0251     """
0252     from django.utils import timezone
0253 
0254     if eic is not None and not isinstance(eic, bool):
0255         raise AuthorityError(
0256             f'eic must be true, false or null, got {type(eic).__name__}')
0257     # The sweep runs at sign-in, so the moment membership was last observed
0258     # is the moment the person last signed in — the only sign-in time this
0259     # side can know, since a proxied request never opens a session here.
0260     # A completed observation supersedes any earlier failed one.
0261     changes = {'eic': eic, 'eic_at': timezone.now().isoformat(),
0262                'check_failed': None, 'check_failed_at': None}
0263     if github is not None:
0264         if not isinstance(github, str):
0265             raise AuthorityError(
0266                 f'github must be a string, got {type(github).__name__}')
0267         changes['github'] = github or None
0268     return _write(username, changes)
0269 
0270 
0271 def set_check_failed(username, reason, github=None):
0272     """Record that a sign-in's membership check did not complete.
0273 
0274     The sweep's second setter. It stamps the reason and the time and leaves
0275     ``eic`` as it was: a check that reached no answer is not an observation,
0276     and recording a negative on a revoked token or a network failure would
0277     strip access from an account that holds it. The next completed check
0278     (``set_eic``) clears both fields; until then the failure is surfaced by
0279     the ``authority_check`` alarm and on the User admin page.
0280     """
0281     from django.utils import timezone
0282 
0283     if not isinstance(reason, str) or not reason.strip():
0284         raise AuthorityError('check_failed must be a nonempty reason')
0285     changes = {'check_failed': reason.strip()[:500],
0286                'check_failed_at': timezone.now().isoformat()}
0287     if github is not None:
0288         if not isinstance(github, str):
0289             raise AuthorityError(
0290                 f'github must be a string, got {type(github).__name__}')
0291         changes['github'] = github or None
0292     return _write(username, changes)
0293 
0294 
0295 def set_role(username, role, held):
0296     """Grant or clear a role (``pac`` or ``ops``). A person's setter, like
0297     ``set_rights``; ``held=False`` or None clears it. Clearing ``ops`` on a
0298     record still carrying the former ``rights: ops`` rung lowers that rung
0299     to ``basic``, so the clearing is not undone by the compatibility read."""
0300     if role not in ROLE_KEYS:
0301         raise AuthorityError(f'role must be one of {ROLE_KEYS}, got {role!r}')
0302     if held is not None and not isinstance(held, bool):
0303         raise AuthorityError(
0304             f'{role} must be true, false or null, got {type(held).__name__}')
0305     changes = {role: True if held else None}
0306     if role == OPS_KEY and not held \
0307             and get_authority(username).get('rights') == RIGHTS_OPS:
0308         changes['rights'] = RIGHTS_BASIC
0309     return _write(username, changes)
0310 
0311 
0312 def set_pac(username, pac):
0313     """Grant or clear the physics analysis coordinator role."""
0314     return set_role(username, PAC_KEY, pac)
0315 
0316 
0317 def set_ops(username, ops):
0318     """Grant or clear the operations role."""
0319     return set_role(username, OPS_KEY, ops)
0320 
0321 
0322 def set_rights(username, rights):
0323     """Grant, restrict, or clear an account's rights. A person's setter.
0324 
0325     Writes the granted field and nothing else: an administrative decision
0326     cannot assert a GitHub fact. ``rights=None`` clears the field back to
0327     unset, returning the account to drawing authority from ``eic`` alone.
0328     """
0329     if rights is not None and rights not in RIGHTS_VALUES:
0330         raise AuthorityError(
0331             f"rights must be one of {', '.join(RIGHTS_VALUES)}, or null; "
0332             f'got {rights!r}')
0333     return _write(username, {'rights': rights})