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
0035
0036 RIGHTS_READ = 'read'
0037 RIGHTS_BASIC = 'basic'
0038 RIGHTS_OPS = 'ops'
0039 RIGHTS_VALUES = (RIGHTS_READ, RIGHTS_BASIC, RIGHTS_OPS)
0040
0041
0042 RIGHTS_GRANTING = (RIGHTS_BASIC, RIGHTS_OPS)
0043
0044
0045
0046
0047
0048
0049
0050
0051 PAC_KEY = 'pac'
0052 PAC_MEANING = ('Set request priorities, as a physics analysis coordinator '
0053 '(PAC).')
0054
0055
0056
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
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
0070
0071
0072
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
0099
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
0177
0178
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
0258
0259
0260
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})