Warning, /swf-monitor/docs/AUTHORITY.md is written in an unsupported language. File is not indexed.
0001 # Authority — read, and act
0002
0003 Any GitHub account can sign in to `epic-devcloud.org` and reach swf-monitor
0004 through swf-remote. As the monitor gains functions that change the system,
0005 reading and acting separate:
0006
0007 - **Read** needs a signed-in account and nothing more, at every level.
0008 Collaborators outside the `eic` GitHub organisation, demonstrations, and
0009 non-member viewers keep the monitoring view they have today.
0010 - **Act** needs authority: membership of the `eic` GitHub organisation, or
0011 rights granted here.
0012
0013 Every mechanism below is default-deny. An unknown account, an unwritten
0014 field, and an undeclared capability all resolve to refused, so a function
0015 whose protection was overlooked is closed rather than open.
0016
0017 Enforcement is upstream, in swf-monitor, at one point. It covers both faces:
0018 external users arriving through the swf-remote tunnel and users reaching
0019 `pandaserver02` directly. swf-remote does not enforce; it resolves
0020 organisation membership and writes it here.
0021
0022 ## The attribute
0023
0024 Authority is held per account as `UserPreference.prefs['authority']`
0025 (`monitor_app/models.py`, table `user_preference`, keyed by username string).
0026 The key matches the `X-Remote-User` identity the tunnel carries, and the
0027 record needs no Django account: an account created later already carries what
0028 was written for the name.
0029
0030 | Field | Values | Written by |
0031 |---|---|---|
0032 | `eic` | unset, true, false | swf-remote's sign-in sweep, and nothing else |
0033 | `rights` | unset, `read`, `basic`, `ops` | a person, in the User admin page, and nothing else |
0034 | `github` | the GitHub login the membership was observed for | the sign-in sweep, beside `eic` |
0035
0036 ```
0037 may act = rights != 'read' and (eic is true or rights in ('basic', 'ops'))
0038 ```
0039
0040 Provenance is carried by which field a value sits in, not by a set-by marker
0041 inside one field. The consequences are the point of the split:
0042
0043 - The sweep maintains `eic` and nothing else, so a grant made here cannot be
0044 undone by a later sign-in.
0045 - A person maintains `rights` and nothing else, so a member who leaves the
0046 organisation loses authority at their next sign-in, with no one acting.
0047 - `rights: read` is an explicit veto that outranks `eic` — the way to stop a
0048 member of the organisation from acting.
0049 - `rights: unset` is the ordinary state of a member, who draws authority from
0050 `eic` alone.
0051 - An account with no GitHub identity holds `eic: unset` rather than false.
0052 Asserting a GitHub fact about an account that has no GitHub identity would
0053 be untrue, and it is what forced the two fields apart.
0054
0055 Each level names what the person may do rather than what they may not,
0056 because they read it on their own account page: `read`, not "none". `basic`
0057 rather than "user", which is already taken three times over by the Django
0058 model, `UserPreference`, and `X-Remote-User`.
0059
0060 ### The roles: PAC and Ops
0061
0062 | Field | Values | Written by |
0063 |---|---|---|
0064 | `pac` | unset, true | a person, in the User admin page, and nothing else |
0065 | `ops` | unset, true | a person, in the User admin page, and nothing else |
0066
0067 Roles are flags beside the access level, held together or apart. A
0068 physics analysis coordinator (PAC) may set request priorities. Operations
0069 administers accounts on the User admin page, sets priorities, and holds
0070 authority to act on its own, like `basic`. The former `rights: ops` rung
0071 is read as the operations role, so a record written before the roles
0072 existed keeps what it had, and clearing the role on such a record lowers
0073 the rung to `basic`; the User admin page offers `read` and `basic` as the
0074 level and the two roles as checkboxes.
0075
0076 ```
0077 may act = rights != 'read' and (eic is true or rights == 'basic' or ops is true)
0078 may set priority = may act and (pac is true or ops is true)
0079 ```
0080
0081 The physics coordinator's requirement (2026-09-10): with the pages open to
0082 the collaboration, priority must be settable by coordinators only, since
0083 anyone might otherwise raise their own request in good faith. Every
0084 priority control renders only for accounts that may set it (the template
0085 context variable `may_set_priority`), and the priority endpoints refuse a
0086 person who may not with the `PRIORITY_REFUSAL` text, naming the User admin
0087 page as the way to be granted the role. `may_set_priority`, `is_pac`,
0088 `is_ops`, `set_role` (`set_pac`, `set_ops`) and `PRIORITY_REFUSAL` live in
0089 `monitor_app/authority.py`; the roles are written through the same endpoint
0090 as `rights`, `POST /api/user-rights/ {"username": ..., "pac": true|false,
0091 "ops": true|false}`, so the sign-in sweep's endpoint cannot touch them
0092 either.
0093
0094 `monitor_app/authority.py` is the whole namespace: `get_authority`,
0095 `all_authority`, `may_act`, `is_ops`, and the two setters `set_eic` and
0096 `set_rights`, each writing its own field only. `UserPreference.set_pref`
0097 refuses the reserved `authority` key with a `ValueError`, so no general
0098 preferences surface can become a path to privilege.
0099
0100 ## The write endpoints
0101
0102 One endpoint per field. That is what makes the split structural rather than a
0103 convention: the endpoint every sign-in goes through cannot write rights at
0104 all, so no sweep bug and no malformed body can reach them.
0105
0106 ```
0107 POST /api/user-authority/
0108 {"username": "...", "authority": {"eic": true, "github": "..."}}
0109 → {"username": "...", "authority": {"eic": true, "rights": null,
0110 "github": "...", "eic_at": "..."}}
0111
0112 POST /api/user-rights/
0113 {"username": "...", "rights": "basic"}
0114 → {"username": "...", "authority": {...}, "may_act": true}
0115 ```
0116
0117 `github` travels nested inside `authority`, which is the contract; the field
0118 is also accepted beside it. `eic: null` and `rights: null` clear their fields
0119 to unset. Each endpoint refuses the other's field with 400. JSON in, JSON
0120 out, never a redirect, so both survive the swf-remote hop
0121 ([EXTERNAL_ACCESS.md](EXTERNAL_ACCESS.md)). Through Apache the paths carry
0122 the `/swf-monitor/` prefix.
0123
0124 `eic_at` is stamped by every membership write. The sweep runs at sign-in, so
0125 it is the moment the person last signed in — the only sign-in time this side
0126 can know, since a proxied request never opens a session here.
0127
0128 ### A check that reached no answer
0129
0130 A sign-in whose membership check could not complete — no stored GitHub token,
0131 GitHub unanswered, the membership write refused — is posted to the same
0132 endpoint in its second form:
0133
0134 ```
0135 POST /api/user-authority/
0136 {"username": "...", "authority": {"check_failed": "<reason>", "github": "..."}}
0137 ```
0138
0139 `set_check_failed` stamps `check_failed` and `check_failed_at` and leaves
0140 `eic` as it was, since a check without an answer is not an observation. The
0141 next completed check clears both. Exactly one of `eic` or `check_failed` is
0142 accepted per request.
0143
0144 Every GitHub sign-in therefore ends in one of two writes, and anything else
0145 is surfaced rather than logged:
0146
0147 - the person is told at sign-in that membership could not be checked, that
0148 reading is unaffected, and that signing in again retries;
0149 - the `authority_check` alarm (`alarms/swf_alarms/alarms/authority_check.py`)
0150 fires per account while `check_failed` is set, and per account that
0151 appeared here more than an hour ago with no authority record at all — the
0152 case where even the failure report did not arrive;
0153 - the User admin page marks the account "last check failed".
0154
0155 From the 9/9 backfill to 2026-09-25 swf-remote stored no OAuth tokens
0156 (django-allauth 65 keeps none unless `SOCIALACCOUNT_STORE_TOKENS` is set), so
0157 no sign-in in that period wrote anything and nothing said so. The guard exists
0158 so that this failure cannot be silent again.
0159
0160 The backfill writes both fields and so uses both endpoints: membership for
0161 the GitHub-linked accounts through the first, `rights: basic` for the
0162 grandfathered non-members and for the accounts with no GitHub identity
0163 through the second.
0164
0165 Writing authority is not a user capability. The membership endpoint requires
0166 the swf-remote authority writer — the service identity `swf-remote-authority`
0167 presented as `X-Remote-User` over the localhost tunnel — or an authenticated
0168 superuser on this side. A request carrying a signed-in person's identity is
0169 refused with 403 whatever that person's authority, so the endpoint stays
0170 closed even if it is ever reachable through the proxy. The rights endpoint
0171 additionally accepts the people who may use the User admin page: staff, and
0172 accounts holding `ops`.
0173
0174 That the identity is a service name rather than a person is what has to hold,
0175 and it rests on three properties of the swf-remote side: the sign-in sweep
0176 presents that identity, the proxy sets `X-Remote-User` itself and never
0177 forwards a client-supplied value, and `swf-remote-authority` cannot be
0178 claimed as an account name there. The third was a real opening: sign-up
0179 adopts the GitHub login as the username, so a collaborator registering that
0180 name on GitHub would have arrived wearing the service identity. It is closed
0181 by an account-name blacklist covering the service names.
0182
0183 There is at present no route to either endpoint through the proxy — its
0184 catch-alls cover only `panda/` and `pcs/`, and no explicit entry names them.
0185 The identity check is what stays true if an `api/` catch-all is ever added,
0186 which would otherwise turn every signed-in user into someone who can write
0187 their own privilege.
0188
0189 ## The two pages
0190
0191 Both live here, beside the data, and so serve the internal face as well as
0192 the external one.
0193
0194 **A person's own account page** states their standing in plain language:
0195 whether they may act, which GitHub login was checked, and, when they may not,
0196 the joining procedure. What the person reads is what drove the naming of the
0197 levels, so the page is descriptive rather than punitive.
0198
0199 **User admin**, under the System menu, visible to staff and to accounts
0200 holding the operations role. It lists accounts with their GitHub login,
0201 origin, `eic`, `rights`, the PAC and Ops roles, and last sign-in, and it is
0202 where `rights` and the roles are set. `eic` is display-only there: it
0203 is observed from GitHub, and a hand edit would be overwritten at the next
0204 sign-in.
0205
0206 ### The refusal
0207
0208 Enforcement is on this side, so the refusal a non-member sees is rendered
0209 here. GitHub has no self-service join: an organisation owner has to issue the
0210 invitation, so a bare 403 leaves the person with nowhere to go. The refusal
0211 names the GitHub login that failed the check, states that reading is
0212 unaffected, and links the joining procedure:
0213
0214 > Your GitHub account `<login>` is not a member of the `eic` organization.
0215 > ePIC production monitoring requires membership for actions against the
0216 > production system; reading monitoring information does not. The joining
0217 > procedure is on the ePIC Software & Computing Get Started page,
0218 > https://eic.github.io/documentation/getstarted.html — see "Join GitHub".
0219 > Once you have been added, sign in again and your account will work.
0220
0221 The procedure is linked, not restated. The published sources disagree on the
0222 contact address — the Get Started page gives one list address and the `eic`
0223 organisation profile another — and a copy here would be a third that goes
0224 stale independently.
0225
0226 ## Enforcement
0227
0228 One rule: **a person who writes must hold authority.** It is applied at one
0229 point on each of the two surfaces a person can write through, and it is
0230 keyed on the expression above, never on `eic` alone.
0231
0232 **The REST and page surface.** `AuthorityGateMiddleware`
0233 (`monitor_app/middleware.py`) runs after the tunnel middleware, so a request
0234 that carries a person — a browser session, or the identity swf-remote
0235 forwards as `X-Remote-User` — is authenticated by then. On `POST`, `PATCH`,
0236 `PUT` or `DELETE` that person must `may_act`; otherwise the request is
0237 refused with the joining procedure, as JSON on the API paths and as a page
0238 elsewhere. `GET`, `HEAD` and `OPTIONS` pass on sign-in alone. Signing in and
0239 out, changing a password, and the two authority endpoints — which admit only
0240 the swf-remote service identity or an administrator — are exempt.
0241
0242 A request carrying **no person** — machinery on a service token, or nothing
0243 — is left to the view's own authentication, exactly as before. The gate adds
0244 protection for people and removes nothing else. This is what keeps the
0245 testbed running: the agents' log posts (some ten thousand a day, open by
0246 design), the heartbeats, the host reporters, the episode builder, the
0247 production-record ingest and the registrar all authenticate by token and
0248 carry no person. The `02:47 catalog_sync` chain publishes in process and
0249 never crosses the web tier at all.
0250
0251 **The MCP surface.** MCP is a separate service calling the service layer in
0252 process, so a Django middleware never sees it. Each tool
0253 that changes the system carries `@requires_authority` under its
0254 `@mcp.tool()` (`monitor_app/mcp/common.py`): a tunnel caller is a person
0255 and must `may_act`; a bearer-token caller carries no person and passes, as
0256 on the REST face. `AUTHORITY_GUARDED_TOOLS` declares them, and
0257 `tests/test_authority.py` asserts that the guarded set and the declared set
0258 are identical, so a write tool cannot be added without being declared and a
0259 declared name cannot go stale.
0260
0261 **Observe, then enforce.** The SysConfig knob `authority_enforce` (false
0262 until set) decides whether the gates refuse or only observe. While false,
0263 every refusal a gate would have made is logged as a warning naming the
0264 method, path and person, and the request proceeds; the rule is proven
0265 against live traffic before it bites. Setting it true on the System page
0266 turns refusal on with no deploy.
0267
0268 ### The tunnel identity fallback
0269
0270 `TunnelAuthentication` used to answer a localhost request carrying no
0271 `X-Remote-User` with a generic `swf-remote-proxy` user, before any token was
0272 read. A valid token, a garbage token and no credential at all were all that
0273 user. The hourly production-record writer, whose default endpoint is the
0274 localhost face, ran under it: 65 PCS rows and, in one week, 24 production
0275 action records including task submissions were attributed to it. The
0276 fallback is removed; a localhost request without the header falls through to
0277 session or token authentication. swf-remote names its service identity
0278 explicitly on the calls that need one, all of them reads.
0279
0280 ### What cannot carry the gate
0281
0282 `log_epicprod_action` runs at or after execution and its contract is that it
0283 never raises, so a gate there would fail open. `ACTION_DEFAULTS` is not
0284 authoritative either: `epicprod_logging.py` computes the known actions as the
0285 catalog plus any action observed in the log, which means the code expects
0286 call sites that were never declared — exactly the forgotten action a gate
0287 must catch. And a gate keyed on the service token would exempt six people
0288 who hold one and, since the agents authenticate with a person's token, would
0289 either exempt that person or stop every agent.
0290
0291 ### Tokens
0292
0293 Six people and four services hold API tokens. A person's token is that
0294 person: on the external face the proxy forwards `Authorization` and sets
0295 `X-Remote-User` from the session, so the person is present and gated; on the
0296 internal face a bare token carries no person and is governed by the view's
0297 authentication, as any API token is. Token issuance is the control there.
0298 The agents authenticate with a person's token; giving the machinery its own
0299 identity is a separate cleanup.
0300
0301 ## Rollout
0302
0303 The order matters: enforcement before backfill denies every write for
0304 everyone.
0305
0306 1. **Store, inert** (swf-monitor). The namespace, the two setters, the
0307 reserved-key guard, the endpoint, and the two pages. Nothing enforces.
0308 2. **Resolve and backfill** (swf-remote). `read:org` added to the GitHub
0309 scope, membership resolved at sign-in and rewritten on every sign-in, and
0310 every existing account populated: `eic` for the GitHub-linked accounts,
0311 `rights: basic` for those with no GitHub identity, whose access is
0312 established inside the BNL perimeter through the account sync.
0313 3. **Enforce** (swf-monitor). The method gate and the agent gate, after every
0314 account carries its field.