Repository navigation
feat(auth): service accounts, so a job can hold an API token without being a person - #570
Merged
Merged
Conversation
…being a person
A token has belonged to a person since /profile started issuing them, and that
is right for people: may_sign_in is re-read on every request, so leaving Janelia
revokes it. A Jenkins job has no such lifecycle, and nothing in the orcid
collection could give it one.
Deliberately NOT a record in orcid. That collection is the author roster - a
dozen sync programs (apply_orcids, pull_openalex_labs, email_authors,
pull_biorxiv and the rest) read every document in it as a real Janelia author -
so a synthetic record there would be name-matched to publications, counted in
author totals and emailed. apply_orcids matches on {given, family} alone, which
is the silent one: a service account could be credited with somebody's paper.
New collection service_account, with name_unique and sa_token_digest indexes
(the latter partial on $exists, NOT $type - a partial index whose filter an
equality query cannot imply is never used by the planner, which is how
orcid_unique spent its life doing collection scans).
{name, kind: "service", owner, purpose, enabled, authorization: [...],
api_token: {digest, created, last_used}, expires, created, updated,
updated_by}
owner is mandatory and is the lifecycle. may_act() refuses an account that is
disabled, expired, or whose owner no longer passes may_be_authorized - accounts
outlive the people who set them up, and without this one would be the first
credential here that never lapses.
Four seams in the application:
- api_token_user() tries orcid first, then service_account. A refused account is
named in the log: disabled and wrong-token otherwise look identical and need
different fixes.
- current_user() says which eligibility rule applies rather than leaning on
may_sign_in for both. That only passed a service account by accident - it has
no alumni flag and no workerType, so the rule written about people never
considered it.
- authorizations() branches on kind, because may_be_authorized demands an ORCID
iD a service account will never have.
- @restricted refuses a service account outright. All 15 restricted routes are
HTML pages that call display_person_name and person['given'][0] on a document
with neither. A service account is for the JSON API.
"admin" cannot be granted to one, in four places: the checkbox is never offered,
both save paths filter against service_terms(), and authorizations() drops it
even from a hand-written record. That last one carries the weight - for a person
"admin" expands to the whole cv, so an account holding it would silently acquire
every term added from then on.
/service_accounts lists and creates; /service_account/<id> edits authorizations,
owner, purpose and expiry, and rotates, disables or deletes. Both @restricted
('admin'), both hidden from the navbar for anyone else by the existing filter,
which reads the decorator rather than a second list. Authorizations are $set
whole - the checkboxes are a complete statement and nothing else writes the
field, unlike the name variants on /person/<id> that need $addToSet because
three sync programs also write them.
Three bugs found reviewing this before commit, all mine, all fixed:
- An apostrophe in an account name silently REMOVED a confirmation prompt.
html.escape turns ' into ', the browser decodes attribute entities before
the JS parses, and confirm('...') terminates early: the handler throws and the
form submits without asking. The guard disappeared exactly when the name was
awkward. Now js_string(), which json.dumps the message.
- A malformed expiry was a 500 rather than a message.
- An unreadable cv would have wiped every authorization on save, because no
checkboxes render and Save writes what it reads.
The same two bugs existed already and are fixed with it: the ignore-list and cv
delete prompts used the single-quoted form, and _save_authorizations had the
same wipe. The person-side guard was worse than it looked - the self-demotion
check caught an admin editing themselves and did nothing for an admin editing
anybody else.
Deploy: api/ -> OpenShift, ocbuild dis-utilities 120.123.0
Writes-to-production: new collection service_account (indexes already created); orcid untouched; no DOI or author data written
Routes: /service_accounts and /service_account/<id>, both @restricted('admin')
Sync-interaction: none - no sync program reads service_account, and nothing new is written to orcid
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A token has belonged to a person since /profile started issuing them, and that is right for people: may_sign_in is re-read on every request, so leaving Janelia revokes it. A Jenkins job has no such lifecycle, and nothing in the orcid collection could give it one.
Deliberately NOT a record in orcid. That collection is the author roster - a dozen sync programs (apply_orcids, pull_openalex_labs, email_authors, pull_biorxiv and the rest) read every document in it as a real Janelia author - so a synthetic record there would be name-matched to publications, counted in author totals and emailed. apply_orcids matches on {given, family} alone, which is the silent one: a service account could be credited with somebody's paper.
New collection service_account, with name_unique and sa_token_digest indexes (the latter partial on $exists, NOT $type - a partial index whose filter an equality query cannot imply is never used by the planner, which is how orcid_unique spent its life doing collection scans).
owner is mandatory and is the lifecycle. may_act() refuses an account that is disabled, expired, or whose owner no longer passes may_be_authorized - accounts outlive the people who set them up, and without this one would be the first credential here that never lapses.
Four seams in the application:
"admin" cannot be granted to one, in four places: the checkbox is never offered, both save paths filter against service_terms(), and authorizations() drops it even from a hand-written record. That last one carries the weight - for a person "admin" expands to the whole cv, so an account holding it would silently acquire every term added from then on.
/service_accounts lists and creates; /service_account/ edits authorizations, owner, purpose and expiry, and rotates, disables or deletes. Both @restricted ('admin'), both hidden from the navbar for anyone else by the existing filter, which reads the decorator rather than a second list. Authorizations are $set whole - the checkboxes are a complete statement and nothing else writes the field, unlike the name variants on /person/ that need $addToSet because three sync programs also write them.
Deploy: api/ -> OpenShift, ocbuild dis-utilities 120.123.0
Writes-to-production: new collection service_account (indexes already created); orcid untouched; no DOI or author data written
Routes: /service_accounts and /service_account/, both @restricted('admin')
Sync-interaction: none - no sync program reads service_account, and nothing new is written to orcid
@stuarteberg