Skip to content

Python API and scoped API tokens - #435

Open
krokicki wants to merge 37 commits into
mainfrom
python-api-tokens
Open

Python API and scoped API tokens#435
krokicki wants to merge 37 commits into
mainfrom
python-api-tokens

Conversation

@krokicki

@krokicki krokicki commented Aug 25, 2026

Copy link
Copy Markdown
Member

Adds scoped API tokens and a Python client, so users can drive Fileglancer from a script or notebook instead of only from the browser.

The motivating workflow, which now works end to end:

import neuroglancer
from fileglancer import Fileglancer

fg = Fileglancer()                                    # reads FILEGLANCER_URL / FILEGLANCER_TOKEN
link = fg.create_data_link("/data/alice/sample.zarr")

state = neuroglancer.ViewerState()
state.layers["sample"] = neuroglancer.ImageLayer(source=f"zarr://{link.url}")

print(fg.create_ng_link(state.to_json(), title="sample"))

How it works

One branch, every route. Token auth is resolved inside auth.get_current_user, the single function all ~49 authenticated routes already depend on. No route was edited, no endpoint changed signature, and cookie users are unaffected — the only edit to shared code is a guard that is a no-op for cookie requests.

Deny-by-default scopes. A path-prefix table maps request paths to files / links / jobs, with GET/HEAD requiring :read and everything else :write. Anything unlisted is unreachable by token, which is what keeps /api/ssh-keys, /api/apps, /api/catalog, /api/preference, /api/ticket and /api/tokens itself session-only — including the case that a token cannot mint another token. A test enumerates app.routes and fails if a new authenticated route is neither scoped nor explicitly session-only, so this cannot drift silently.

Which scopes exist is a server decision. api_token_scopes defaults to everything except files:write and jobs:write. Both amount to full access to the user's files — jobs:write accepts free-form pre_run/post_run shell that runs as the user, so it reaches every file regardless of the files:* scopes granted — so an admin opts into them per server. Enforced at creation and at verification, so removing a scope revokes it from tokens that already hold it rather than only restricting new ones.

Paths, not internal identifiers. The client takes absolute POSIX paths and resolves them to (file share, relative path) entirely client-side, mirroring resolvePathToFsp in the frontend. Nothing was added to the REST API to support it.

Reviewing this

The diff is large but layered; each commit is self-contained and tested. Suggested order:

  1. fileglancer/auth.py — the scope table and get_user_from_token. This is the security boundary; everything else is downstream of it.
  2. fileglancer/database.py + the migration — token storage. Secrets are stored as SHA-256 only and compared with hmac.compare_digest.
  3. fileglancer/server.py — the three /api/tokens routes and the origin-check guard.
  4. fileglancer/client.py — the client, readable top to bottom.
  5. The frontend: apiTokenQueries.ts, then ApiTokens.tsx and the three dialogs.

Worth a careful look:

  • get_user_from_token — the intersection of granted scopes with server-enabled scopes is what makes api_token_scopes a real control. The two 403s are worded differently on purpose: a missing scope is the user's to fix by minting a new token, a disabled one is not, so that message names the administrator.
  • The origin-check skip. server.get_current_user skips enforce_request_origin for bearer auth, because a token is not ambient the way a cookie is. The safety property is that auth.get_current_user never falls back to the cookie once an fgt_ bearer is present — otherwise a cross-origin page could pair a junk token with ambient cookies and skip the check. test_bearer_token_never_falls_back_to_cookie_auth pins it.
  • jobs:write is documented as full-account access in both the create dialog and the user docs, rather than implying containment it does not have.

Testing

  • Backend 917 passing, including a suite for the restricted-scope configuration and one asserting a token loses a scope the server has since disabled.
  • Frontend 330 passing.
  • One Playwright spec covering create → secret shown once → revoke through the confirmation, asserting the captured secret string is absent from the page afterwards.
  • scripts/api_walkthrough.py steps through every client method interactively for manual verification. Verified end to end against a live server: 24 steps, no residue.

Notes for the reviewer

  • Docs live in a separate PR: Document the Python API and API tokens fileglancer-docs#24 — a new Features page covering token creation, scopes, paths, data links, the Neuroglancer workflow, jobs, and errors.
  • docs/config.yaml.template documents api_token_scopes for admins.
  • The design spec is at docs/superpowers/specs/2026-08-24-python-api-design.md, with an Amendments section recording where the implementation deliberately diverged from it — the no-match error no longer enumerating mount points, the dropped cross-language resolver fixture, and three others.
  • Access logs identify token requests. AccessLogMiddleware previously resolved identity from the session cookie only, so every programmatic request logged -. Lines now read [rokickik fgt:4e626080362d], giving an admin the user and the exact token to revoke. The token id is the public half — an independent CSPRNG draw, not a prefix of the secret — and is already stored in plaintext and shown in the GUI.
  • Known and deferred: cancel_job and the client's context-manager methods are untested. An invalid token still logs -, since it never resolves to an identity.

@StephanPreibisch @JaneliaSciComp/fileglancer @stuarteberg

krokicki and others added 30 commits August 24, 2026 18:29
Specs a scoped bearer-token auth mechanism layered onto the existing
get_current_user dependency, GUI token management, and a path-level
Python client shipped inside the fileglancer package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Thirteen TDD tasks: token storage and migration, scope table, bearer
auth wiring, management endpoints, the Python client in four slices,
the GUI page and dialogs, an E2E spec, and user docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires bearer-token auth into the single get_current_user dependency all
~55 authenticated routes already use, so token auth covers the whole API
without touching any route. Cookie/origin enforcement is untouched and
skipped only when a bearer token is present.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds ApiTokenInfo/ApiTokenListResponse/ApiTokenCreateRequest/
ApiTokenCreateResponse models and GET/POST/DELETE /api/tokens routes so
users can create, list, and revoke API tokens. These endpoints are
session-only by omission from the auth.py scope table, so a token can
never be used to mint or revoke another token.

Also un-xfails test_a_token_cannot_mint_another_token from Task 3 (now
a real 403) and adds three regression tests for previously-uncovered
branches: empty-id and empty-secret malformed token parsing, and a
pinned property that a bearer token never falls back to cookie auth.
Task 4 review fix round 1: ApiTokenCreateRequest now strips and
rejects a whitespace-only name via a field_validator (previously
length validation ran before the route's strip, letting " " through
as a stored blank name). create_api_token now deduplicates scopes
before storing them, so ["files:read", "files:read"] no longer stores
as a repeated string.
Adds fileglancer.Fileglancer, the core of the Python client for Tasks
6-8 to build file/link/job methods on top of: construction from
FILEGLANCER_URL/FILEGLANCER_TOKEN or explicit args, HTTP plumbing via
httpx with server error bodies surfaced as FileglancerError, and
client-side absolute-path <-> (file share, relative path) resolution
mirroring resolvePathToFsp in pathHandling.ts.
Adds ls, stat, mkdir, rename, delete, read, write to Fileglancer, each
taking an absolute filesystem path and resolving it via _resolve()
before issuing the corresponding /api/files or /api/content request.

Also fixes Fileglancer's constructor to wrap async-only transports
(httpx.ASGITransport) in a small sync adapter, since httpx.Client is
synchronous and ASGITransport only implements the async transport
interface. This was required for the new tests, which drive the real
app over ASGITransport with no server process.
Replaces the _SyncFromAsyncTransport shim (test-only scaffolding that
had leaked into the shipped client) with fastapi.testclient.TestClient,
which already wraps an ASGI app as a synchronous httpx.Client. Drops
the now-unneeded transport parameter from Fileglancer.__init__.
GET /api/files omits the files key for non-directories, so ls() on a
file previously returned [], indistinguishable from an empty directory.
Now checks info.is_dir and raises FileglancerError first.
Adds create_data_link, data_links, data_link, delete_data_link,
create_ng_link, ng_links, and delete_ng_link to Fileglancer, completing
the create-link -> build-state -> shorten-link workflow. Every returned
ProxiedPath has its path field rewritten to an absolute path.
Adds useApiTokensQuery/useCreateApiTokenMutation/useDeleteApiTokenMutation
in frontend/src/queries/apiTokenQueries.ts, modeled on sshKeyQueries.ts,
to back the upcoming token management UI (Task 9 of the API tokens plan).

Also adds two resolvePathToFsp regression tests (prefix-boundary and
longest-match cases) confirming the TS resolver's prefix guard matches
the behaviour the Python client mirrors.
Review found the two resolvePathToFsp tests added alongside the API
token query hooks were redundant with pre-existing coverage, and in
one case did not actually exercise the prefix-boundary guard it claimed
to test. Reverting pathHandling.test.ts to its exact pre-existing state;
the apiTokenQueries module and its tests are unaffected.
Adds the user-facing API Tokens page (list, revoke, create-dialog with
one-time secret display) consuming the completed apiTokenQueries hooks,
registered unconditionally at /api-tokens and linked from the profile
menu. Uses the existing FgDialog wrapper and design-system form atoms
(FgFormField/FgInput/FgSelect/FgCheckbox) rather than raw Material
Tailwind dialog/inputs, following SSHKeys/TempKeyDialog precedent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round 1 fixes: reset the create-token mutation on close so a
stale error/secret doesn't reappear on reopen, ignore close attempts
while a create is in flight so cancel can't race the response into
showing the secret dialog, add a RevokeTokenDialog confirmation step
before a token is actually revoked, and scope the revoke-pending state
to the specific token being revoked instead of disabling every card.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the full user journey on the API Tokens page: create a token,
confirm the secret is shown exactly once and never appears again, then
revoke it through the confirmation dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
api_tokens is per-test-user state of the same class as user_preferences
but was missing from cleanDatabase's table list, letting tokens leak
across local E2E runs (the webServer config reuses a fixed sqlite path).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Whitespace-only reformat (multi-line locator chains, wrapped tables
array); no logic changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Directly prove the confirmation gate's property: clicking the card's
Revoke button opens the dialog but does not itself remove the token,
before the Revoke Token click confirms and removes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/nearline/... is a real site storage-tier name (checked for in
StartTour.tsx), not a generic example. Swap for /data/alice/... in
the user-facing help(fileglancer.client) docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six fixes from the whole-branch review before merge:

- Serialize API token timestamps with a timezone (_ensure_utc, now
  module-level) so the frontend "Expired" badge isn't wrong outside UTC;
  ApiTokenCard now reuses formatDateOnly instead of a duplicate local
  helper.
- Note in the create-token dialog and docs that jobs:write runs code on
  the user's behalf and therefore has the same file access they do, not
  the containment the scope list implies.
- Drop /api/auth/status and /api/cluster-defaults from the scope tables
  in auth.py - both are unauthenticated routes with no
  Depends(get_current_user), so required_scope is never consulted for
  them.
- Correct a test_client_paths.py docstring that claimed a TypeScript
  fixture-sharing arrangement that doesn't exist.
- _absolutize in client.py now returns a data link unmodified (path left
  FSP-relative) when its file share has disappeared from
  file_share_paths, instead of raising and breaking the whole listing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each scope checkbox now states what it allows, folded into the label so
the description is associated with the input for screen readers rather
than merely adjacent to it. Descriptions are a total Record over ApiScope,
so adding a scope without describing it fails to compile.

Selecting files:write or jobs:write surfaces a plain-English warning that
the token is password-equivalent: whoever holds it acts as the user, and
jobs:write in particular runs arbitrary code and so reaches every file
regardless of the file scopes granted.

jobs:read is described as reaching full job detail, parameters,
environment, and log files, which is what the /api/jobs prefix actually
grants -- "List jobs" understated it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Widens the create dialog to max-w-4xl (twice max-w-md) for the added text,
and lays the scopes out as a single two-column grid: scope name, then a
greyed description. One grid spanning every row rather than a grid per row
is what aligns the second column, since a CSS grid column is as wide as
its widest cell across all rows -- no hard-coded width. Checkboxes are
top-aligned so a wrapping description does not drag them down, and the
em-dashes are gone now that the columns carry the separation.

Selecting a :write scope now checks its :read counterpart and locks it,
replacing the "A :write scope also grants :read" note: the server grants
read implicitly to any write scope, so an unchecked read box was showing
a state the server would not honour. Deselecting write releases the lock
and leaves read granted.

Also fixes a real bug in FgCheckbox: h-4 w-4 is a flex basis, not a floor,
so a flex parent shrank the box to fit a long label -- which rendered the
boxes at visibly different sizes across labels of differing length. Adding
shrink-0 fixes it for every consumer, not just this dialog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Steps through every public Fileglancer client method, printing what it is
about to do and the exact call before running it, then pausing so the
result can be verified in the web UI or on disk. Enter runs a step, s
skips, q quits.

Everything is created inside one scratch directory under the cwd and
removed in a finally block, including data links and Neuroglancer links,
so quitting partway through or a failing step still cleans up.

Includes the error paths as deliberate steps -- an unresolvable path,
ls() on a file, a cross-share rename, a missing job -- since those
messages are what a user actually meets when something goes wrong.

Verified end to end against a live server with an all-scopes token: 24
steps, no residue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
_resolve rendered "Available mount points:" with nothing after it when the
share list was empty, which reads as though the caller's path were at
fault rather than the server having nothing mounted. Reported from a real
setup with file_share_mounts: [] in config.yaml.

Now distinguishes the two cases and names the setting to change. The
walkthrough script checks the same condition up front so it can print the
config snippet, and its unreachable-server message mentions the scheme,
since a server started with --ssl-keyfile needs https://.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A real deployment mounts hundreds of shares, so listing them all buried
the one line the caller needed to read. The message now names the
offending path and points at file_share_paths() for discovery, which is a
fixed ~100 characters regardless of how many shares exist.

The empty-share-list case keeps its own distinct message, since there the
server configuration really is the thing to look at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
krokicki and others added 3 commits August 25, 2026 11:14
Appends an Amendments section rather than editing the approved design, so
the document records what was decided on 2026-08-24 and, separately, where
the shipped code deliberately went elsewhere.

Chief among them: the no-match resolution error no longer enumerates the
mount points. The spec called that message load-bearing, which was right --
but a real deployment mounts hundreds of shares, so listing them buried the
line the caller needed. The spec's intent is what motivated the change.

Also records the dropped cross-language resolver fixture, the two dead
scope-table rows, the untouched TableCard DataType, the UTC offset on token
timestamps, and jobs:write being full-account access.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds api_token_scopes, defaulting to every scope except files:write and
jobs:write. Both amount to full access to the user's files -- jobs:write
runs arbitrary code as the user, so it reaches every file regardless of the
files:* scopes granted -- so an admin now opts into them per server rather
than inheriting them.

Enforced in two places, which is what makes the setting a control rather
than a formality: POST /api/tokens refuses a disabled scope, and token
verification intersects each token's granted scopes with the enabled set,
so removing a scope revokes it from tokens that already hold it instead of
only restricting new ones. A files:write token degrades to files:read.

The two 403s are worded differently on purpose. A missing scope is the
user's to fix by minting a new token; a scope the server does not support
is not, so that message names the administrator.

An unknown scope name in the config fails at startup rather than silently
narrowing what users can mint. The creation dialog offers only supported
scopes, constrains its selection to them so a stale default cannot be
submitted, and says to contact an admin when any are withheld.

Cookie sessions are unaffected: the setting governs tokens only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A failed DELETE left the confirmation dialog open with no explanation.
Nothing implied success -- the dialog stays open and the token remains
listed, since invalidateQueries only runs on success -- but isPending
cleared and the button re-enabled, so clicking again failed identically
with no clue why: a 404 because someone else revoked it, a 403 from an
expired session, a dropped connection.

The dialog now renders the error, matching what CreateTokenDialog already
does. Closing it resets the mutation, or a failure on one token would greet
the user when they opened the dialog for a different one -- the same stale
error bug already fixed on the create side. Closing is ignored while a
revoke is in flight, so the outcome is always shown.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
krokicki and others added 4 commits August 25, 2026 13:13
…ests

Windows CI caught two things.

abspath joined a '/'-separated relative path onto mount_path verbatim, so a
Windows mount path came back with mixed separators:
'C:\shares\data/sub/file.txt'. _resolve already normalizes backslashes to
'/' on the way in; abspath now does the same on the way out, so the round
trip is self-consistent and the documented "always in Linux form" claim
holds. No effect on POSIX, where mount_path has no backslashes.

The remaining failures were the tests, not the code: they compared client
output against os.path.join output literally. Windows spells one directory
several ways -- separator style, 8.3 short names (RUNNER~1 vs runneradmin),
and case -- so a shared conftest.same_path helper normalizes both sides
with normcase plus realpath before comparing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AccessLogMiddleware resolved identity from the session cookie only, so every
programmatic request logged '-'. An admin investigating a misbehaving script
had nothing to trace it to.

get_user_from_token now leaves the resolved username and token id on
request.state, which the middleware reads after awaiting call_next --
request.state is backed by scope["state"], so a value set in a dependency is
visible upstream. Lines become:

  127.0.0.1:37662 [rokickik fgt:4e626080362d] "GET /api/files/... " 200

The token id is the public half, not the secret, and is what the GUI shows,
so it points at the exact token to revoke.

The pre-existing cookie lookup is deliberately left in place rather than
replaced by the same mechanism, even though it duplicates a query the
dependency makes moments later: logout deletes the session during the
request, so resolving identity only after call_next would log '-' for the
request that did the logging out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
secondary is the purple brand accent (#6D28D9, commented "Purple color" in
tailwind.config.js), not a muted grey. Using text-secondary for scope
descriptions, card metadata and the empty state turned most of the page
purple -- including the scope description column, which was supposed to be
"slightly greyed".

Switched to text-foreground/60, the muted-text idiom already used across the
app, and dropped color="secondary" from the empty-state icon in favour of an
explicit text-foreground/40 so it reads as decorative rather than inheriting
by accident. The Apps pages use plain text-foreground throughout and never
reach for secondary; this brings the tokens page in line.

Page palette is now text-foreground for content, /60 muted, /40 decorative,
and error/warning only where they carry meaning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@mkitti

mkitti commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Is the only way to set FILEGLANCER_URL through the environment?

@krokicki

Copy link
Copy Markdown
Member Author

Is the only way to set FILEGLANCER_URL through the environment?

No, you can also set it when creating the API object:

fg = Fileglancer(url="https://your-fileglancer-server", token="fgt_...")

Comment thread fileglancer/client.py
Comment on lines +38 to +40
url: Fileglancer server URL. Defaults to $FILEGLANCER_URL.
token: An API token created in the web UI. Defaults to
$FILEGLANCER_TOKEN.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be clearer to say os.environ.get("FILEGLANCER_URL") and os.environ.get("FILEGLANCER_TOKEN") here. It took me a minute to realize that this was not string interpolation.

Comment thread fileglancer/client.py
Comment on lines +48 to +54
if not url:
raise FileglancerError(
"No Fileglancer server URL. Pass url= or set FILEGLANCER_URL.")
if not token:
raise FileglancerError(
"No API token. Pass token= or set FILEGLANCER_TOKEN. Create a "
"token on the API Tokens page of the Fileglancer web UI.")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should do more validation of the URL and token in this constructor.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants