File indexing completed on 2026-09-28 09:37:30
0001
0002 """Populate the account authority attributes for accounts that predate them.
0003
0004 An account may act when `rights` is not `read` and either `eic` is true or
0005 `rights` grants it. Accounts created before either field existed carry
0006 neither, so they can read but not act. This writes what each account is
0007 entitled to, once.
0008
0009 What gets written, and why each is the honest value:
0010
0011 GitHub-linked accounts get `eic` from the organization's member listing,
0012 together with the GitHub login the check was made against. Membership is
0013 read through the `gh` CLI rather than from each account's own OAuth token:
0014 every existing token was issued before the read:org scope was added and
0015 cannot answer the question. The listing is authoritative for private
0016 membership provided the token's owner is an organization member.
0017
0018 Accounts with no GitHub identity get `rights: basic` and no `eic` at all.
0019 Their access was established inside the BNL authentication perimeter,
0020 through the swf-monitor account sync from pandaserver02. Writing `eic:
0021 false` for them would be recording a GitHub fact about an account that has
0022 no GitHub identity, and writing `eic: true` would be a fabrication.
0023
0024 GitHub accounts that are not organization members get `eic: false` — the
0025 observation — and also `rights: basic`, grandfathering the people already
0026 working on the system while they go through the joining procedure. That
0027 grant is a person's decision, not an observation, which is why it lands in
0028 `rights`; a later sign-in re-observes `eic` and leaves `rights` untouched.
0029
0030 Dry run by default. Pass --apply to write.
0031
0032 scripts/backfill_authority.py
0033 scripts/backfill_authority.py --apply
0034
0035 --eic-only re-observes membership for every GitHub-linked account and writes
0036 `eic` alone, as a sign-in would; it never writes `rights`, so no grant or veto
0037 a person has made since the first backfill is touched.
0038
0039 scripts/backfill_authority.py --eic-only --apply
0040 """
0041 from __future__ import annotations
0042
0043 import argparse
0044 import os
0045 import subprocess
0046 import sys
0047 from pathlib import Path
0048
0049 SRC = Path(__file__).resolve().parent.parent / 'src'
0050 sys.path.insert(0, str(SRC))
0051 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'swf_remote_project.settings')
0052
0053 import django
0054 django.setup()
0055
0056 from django.conf import settings
0057 from django.contrib.auth.models import User
0058 from allauth.socialaccount.models import SocialAccount
0059
0060 from remote_app import authority
0061
0062
0063 def org_members(org: str) -> set[str]:
0064 """Lowercased logins of every visible member of the organization."""
0065 out = subprocess.run(
0066 ['gh', 'api', '--paginate', f'/orgs/{org}/members?per_page=100',
0067 '--jq', '.[].login'],
0068 capture_output=True, text=True, check=True,
0069 )
0070 return {line.strip().lower() for line in out.stdout.splitlines() if line.strip()}
0071
0072
0073 def main() -> int:
0074 ap = argparse.ArgumentParser(description=__doc__)
0075 ap.add_argument('--apply', action='store_true',
0076 help='write the attributes; otherwise report only')
0077 ap.add_argument('--eic-only', action='store_true',
0078 help='re-observe membership of GitHub accounts; never write rights')
0079 args = ap.parse_args()
0080
0081 org = settings.EIC_ORG
0082 members = org_members(org)
0083 print(f'{len(members)} members visible in the {org} organization\n')
0084
0085 links = {
0086 s.user_id: (s.extra_data or {}).get('login')
0087 for s in SocialAccount.objects.filter(provider='github')
0088 }
0089
0090
0091
0092 plan: list[tuple[str, bool | None, str, str | None, str]] = []
0093 for user in User.objects.order_by('username'):
0094 login = links.get(user.id)
0095 if login:
0096 member = login.lower() in members
0097 if member:
0098 plan.append((user.username, True, login, None,
0099 f'{login} is a member; access follows eic'))
0100 else:
0101 plan.append((user.username, False, login, 'basic',
0102 f'{login} is not a member; granted basic'))
0103 else:
0104 plan.append((user.username, None, '', 'basic',
0105 'no GitHub identity; established through the BNL account sync'))
0106
0107 if args.eic_only:
0108 plan = [(name, eic, github, None, basis.split(';')[0])
0109 for name, eic, github, _, basis in plan if eic is not None]
0110
0111 width = max(len(name) for name, _, _, _, _ in plan)
0112 for name, eic, _, rights, basis in plan:
0113 shown = ', '.join(
0114 p for p in (f'eic={eic}' if eic is not None else '',
0115 f'rights={rights}' if rights else '') if p)
0116 print(f'{name:{width}s} {shown:24s} {basis}')
0117
0118 members_n = sum(1 for _, e, _, _, _ in plan if e is True)
0119 granted = sum(1 for _, _, _, r, _ in plan if r == 'basic')
0120 print(f'\n{members_n} recorded as members, {granted} granted basic, '
0121 f'{len(plan)} accounts')
0122
0123 if not args.apply:
0124 print('\nDry run. Pass --apply to write.')
0125 return 0
0126
0127 failures = 0
0128 for name, eic, github, rights, _ in plan:
0129 if eic is not None and not authority.record_membership(name, eic, github):
0130 failures += 1
0131 print(f'FAILED (membership): {name}')
0132 if rights and not authority.record_rights(name, rights):
0133 failures += 1
0134 print(f'FAILED (rights): {name}')
0135 print(f'\nfailures: {failures}')
0136 return 1 if failures else 0
0137
0138
0139 if __name__ == '__main__':
0140 sys.exit(main())