Skip to content

feat(auth): service accounts, so a job can hold an API token without being a person - #570

Merged
robsv merged 1 commit into
mainfrom
feat-service-accounts
Oct 20, 2026
Merged

robsv merged 1 commit into
mainfrom
feat-service-accounts

Conversation

@robsv

@robsv robsv commented Oct 20, 2026

Copy link
Copy Markdown
Collaborator

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/ 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

…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 &#x27;, 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>
@robsv robsv self-assigned this Oct 20, 2026
@robsv robsv added the enhancement New feature or request label Oct 20, 2026
@robsv
robsv merged commit 94a80fc into main Oct 20, 2026
2 checks passed
@robsv
robsv deleted the feat-service-accounts branch October 20, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant