| doc_status | living |
|---|---|
| sync_policy | Update with setup, admin workflow, environment variable, and management command changes. |
| last_reviewed | 2026-09-23 |
This manual covers initial system setup and day-to-day administration
through the Django admin console (/admin/). For end-user workflows
(logging in, browsing datasets, accepting TOS) see the
User Manual. For programmatic non-human identities
(CI jobs, backend services), see Service Accounts.
cd dsg
pixi install
pixi run setupThe setup wizard prompts for the public origin, port, secret key, allowed
hosts, and other settings. It also checks for Google OAuth credentials and
prints step-by-step instructions if they are missing. After generating
.env, it runs migrations and seeds the database with default permissions
and groups. You can re-run pixi run setup at any time to update
settings — existing values are shown as defaults.
Migrations also create dsg_cache_table, the shared permission-cache table.
If an older or manually provisioned deployment does not have it, the fallback
is safe to run repeatedly:
pixi run python manage.py createcachetable dsg_cache_tableThe Docker deploy script migrates with a one-off container before starting the new application container, so the cache table is available on its first request. The database cache makes invalidation visible across workers, but leave the production one-worker default in place until a two-worker warm/mutate/read smoke check shows no SQLite lock errors.
pixi run make-admin user@example.comThis bootstraps the first admin from a shell, before anyone can reach the
admin console. It works for both local and Docker deployments — it automatically
detects a running container. It creates the user if they don't exist, sets admin=True, and
prompts for a password. If the user already exists (e.g., from an import or OAuth
login), it promotes them and adds a password. Use --no-password to skip
the password prompt, or --remove to revoke admin status.
After that, promote admins in the admin console: open Core › Users, find
the person, check Admin, and save (uncheck it to revoke). The person needs a
user row first, so have them sign in with Google once, or import them. Prefer
the checkbox day to day: it needs no shell on the server and records who made
the change in the admin history. From a shell, make-admin EMAIL --no-password
does the same promotion and also works for someone who has not signed in yet
(their first Google sign-in attaches to the row by email).
Admins sign in with Google: /admin/ redirects to a login page whose
Sign in with Google button returns them to the admin console. The email +
password form below that button only works for accounts given a Django password
by make-admin; keep one such account (typically the bootstrap admin) as a
break-glass door in case Google sign-in is unavailable.
bash scripts/manage.sh import_clio_auth path/to/clio_export_auth.jsonDocker deployment: The file must be accessible inside the container. Copy it in first:
docker compose cp path/to/clio_export_auth.json dsg:/tmp/ bash scripts/manage.sh import_clio_auth /tmp/clio_export_auth.json
This imports users, datasets, grants, groups, and dataset-admin assignments from a Clio export. It is idempotent — running it again will skip records that already exist.
Local development:
pixi run serveUse pixi run serve-bg to run detached (survives logout); logs are
appended to dsg/serve.log, PID stored in dsg/serve.pid. Stop with
pixi run stop-serve.
Docker deployment:
pixi run deployIf .env doesn't exist yet, the setup wizard runs automatically.
The admin console is at /admin/ (sign in with the Google button).
pixi run serve-bg appends everything Django logs — request lines,
dsg.authz decisions and the ngauth.views token-issuance records — to
dsg/serve.log and never rotates it. Until serving moves to gunicorn under
systemd (where journald owns retention), rotate it with logrotate and ship
the rotated files to the same nearline directory that holds the SQLite
backups. Three files under dsg/scripts/ do this:
| File | Role |
|---|---|
datasetgateway-logrotate.conf |
logrotate stanza: copytruncate, size 50M, plaintext, rotated copies in dsg/logs/serve.log-YYYYmmddTHHMMSS |
datasetgateway-logrotate.service / .timer |
oneshot + daily timer: run logrotate, then the shipper |
ship-rotated-logs.sh |
copy every dsg/logs/serve.log-* to $DSG_BACKUP_DIR/logs/, verify sha256 on the target, then keep only the two newest locally |
Why copytruncate. serve.sh --detach starts the server with
nohup … >> serve.log 2>&1 &, so the process holds one O_APPEND descriptor
on serve.log for its whole life and never reopens it. logrotate therefore
copies the content out and truncates the original in place; the next write
lands at the new end of file, with no restart, no signal and no sparse gap.
Rename-and-recreate rotation would leave the server writing to the renamed
file forever. The only loss is whatever the server writes between the copy
and the truncate (typically nothing; at most a line or two under load).
Retention. Rotated files are uncompressed and unencrypted, both locally
and on nearline (the nearline directory carries the same owner and group as
serve.log). The shipper keeps the two newest rotated files in dsg/logs/
for quick reading and deletes older ones only after their nearline copy has
been verified; nearline keeps everything. The stanza's rotate 60 is just a
cap on unshipped backlog if nearline is unreachable for weeks. size 50M is
a threshold checked once a day, not a ceiling.
Install (root, one session). Requires DSG_BACKUP_DIR in dsg/.env
(see Backups) and that directory to exist — the shipper refuses
to create the nearline root, so an unmounted share fails loudly instead of
filling the local disk. Placeholders are the same as in
datasetgateway-backup.service.
cd /path/to/DatasetGateway/dsg
sed -e 's#<path-to>#/path/to#g' -e 's#<dsg-user>#USER#g' -e 's#<dsg-group>#GROUP#g' \
scripts/datasetgateway-logrotate.conf > /etc/dsg/datasetgateway-logrotate.conf
cp scripts/datasetgateway-logrotate.service scripts/datasetgateway-logrotate.timer /etc/systemd/system/
# edit User=, Group=, WorkingDirectory=, EnvironmentFile= and the ExecStart/ExecStartPost paths
systemctl daemon-reload
systemctl enable --now datasetgateway-logrotate.timer
systemctl list-timers datasetgateway-logrotate.timersystemctl start datasetgateway-logrotate.service runs a check by hand; it
rotates only if serve.log is above 50 MB and then ships. Failures are in
journalctl -u datasetgateway-logrotate and systemctl status.
Bootstrap an oversized existing serve.log. If the file is already far
above the threshold, move its history to nearline before enabling the
timer, running as the service user (not root, so the state file and
dsg/logs/ are owned correctly):
cd /path/to/DatasetGateway/dsg
logrotate --force --state "$PWD/logrotate.state" /etc/dsg/datasetgateway-logrotate.conf
ls -l /proc/$(cat serve.pid)/fd | grep serve.log # still the live file
set -a; . ./.env; set +a
scripts/ship-rotated-logs.sh # copies logs/serve.log-<stamp> to $DSG_BACKUP_DIR/logs/
sha256sum logs/serve.log-* "$DSG_BACKUP_DIR"/logs/serve.log-* # pairs must matchDo not use gzip … && truncate or > serve.log shortcuts: they leave the
history outside dsg/logs/, where the shipper never looks.
Remove at the gunicorn cutover. When datasetgateway.service replaces
serve-bg, run scripts/ship-rotated-logs.sh one last time (with
DSG_LOG_KEEP_LOCAL=0 to flush the local copies), then
systemctl disable --now datasetgateway-logrotate.timer, delete the two
units and /etc/dsg/datasetgateway-logrotate.conf, and daemon-reload.
journald owns local retention from then on; whether journal exports keep
going to nearline is decided at that cutover.
To start completely fresh:
Local development:
cd dsg
rm db.sqlite3
pixi run setup # re-runs migrations and seeds
pixi run make-admin user@example.comDocker deployment:
cd dsg
docker compose down -v # removes containers and database volume
pixi run deploy # rebuilds, runs migrations and seeds
pixi run make-admin user@example.comThe admin console at /admin/ lets you view and edit all data in the
system. It is organized into sections.
| Task | Where |
|---|---|
| Create/edit datasets, versions, TOS documents | Admin console |
| Manage group memberships and group-dataset permissions | Admin console |
| View audit logs | Admin console |
| Manage API keys | Admin console |
| Grant/revoke user access to a dataset | Web UI (/web/grants/<dataset>) — preferred for day-to-day use |
| Export who can get into a dataset (CSV) | Web UI (/web/grants/<dataset>, Export CSV) — dataset manage/admin. See the User Manual. |
| Promote dataset admins | Web UI (/web/dataset-admins/<dataset>) — SC/admin only |
| Manage team members and group grants | Web UI (/web/group/<group>/) — group admins/team leads |
| Manage public roots | Web UI (/web/public-roots/<dataset>) |
| Create / manage service accounts and their tokens | Web UI (/web/service-accounts) — admin only. See Service Accounts. |
The web UI enforces authorization rules (only team leads can manage their groups, only SC/admin can promote team leads). The admin console bypasses all of that — any superuser can edit anything. Use the web UI for routine operations and the admin console for initial setup, bulk changes, or debugging.
This is where all DatasetGateway-specific data lives.
Each row is a DatasetGateway user. Key fields:
| Field | Meaning |
|---|---|
| The user's identity. Must be unique. | |
| Name | Display name (often populated from Google profile). |
| Admin | If checked, the user is a global admin — they can manage grants for any dataset and bypass access-mode restrictions. |
| Is active | Unchecked = account disabled. Disabled users cannot authenticate. |
| Read only | If checked, edit permissions are stripped from this user's permission cache. They can only view. |
| Pi | Principal Investigator field (informational, from Clio legacy). |
Inline sections on the User detail page:
- User groups — which groups this user belongs to. Add rows here to put a user in a group.
- API keys — authentication tokens for this user. Each OAuth login creates one. You can see when tokens were created and last used. You generally don't need to edit these, but you can delete old ones.
When to edit Users here: To toggle the admin, is_active, or
read_only flags, or to add/remove group memberships. For bulk user
creation, use import_csv, import_clio_auth, or import_neuprint_auth as appropriate.
Authorization groups (e.g., sc, team_lead, user). Not to be
confused with Django's built-in auth.Group, which is not used and has
been hidden from the admin.
The User groups inline on the Group detail page shows all members.
Add rows here to add users to the group. The is_admin flag on a
UserGroup record designates the user as a team lead for that group.
The abstract permission types. The system ships with four, in a strict
hierarchy (admin > manage > edit > view):
- view — read access to dataset data
- edit — write access to dataset data
- manage — can manage grants within a group (team lead capability)
- admin — full dataset administration (SC-level)
Each level implies all levels below it. You generally never need to add or change these.
Each row is a dataset. Key fields:
| Field | Meaning |
|---|---|
| Name | Slug identifier used in URLs and API responses (e.g., fish2). Lowercase, no spaces. |
| Description | Human-readable description shown on the web UI. |
| Tos | Link to the TOS document users must accept. Leave blank if no TOS is required. |
| Access mode | Closed (invite-only — users need a Grant or admin role) or Public (anonymous and authenticated callers receive view access, subject to TOS). Public never grants write access. |
Public access is read-only. Mutations still require an explicit grant carrying the required write role; publishing a dataset or version does not create a grant and does not make its base data writable.
Inline sections on the Dataset detail page:
- Dataset buckets — the GCS buckets associated with this dataset. These mappings are the authoritative entry point for Neuroglancer token authorization; the same bucket name on several datasets declares union semantics. For the end-to-end checklist that makes a bucket readable through Neuroglancer, see Neuroglancer (ngauth) Bucket Setup.
- Dataset versions — the versioned releases. Each version can be linked to one or more dataset buckets via the Buckets M2M field.
- Grants — users with
adminpermission on this dataset can manage all grants via the web UI. Team leads (users withmanagepermission) can manage grants within their group via the team dashboard. - Service tables — maps CAVE service/table names to this dataset. Only needed for CAVE API compatibility.
Versioned releases of datasets. Usually edited inline on the Dataset page, but also available as a standalone list for searching across all datasets.
| Field | Meaning |
|---|---|
| Version | Version string exactly as services send it (e.g., v0.7, 2026-01). Unique within the dataset. |
| Branch | Release line; main unless versions fork. Ordinals compare only within a branch. |
| Ordinal | Optional integer order within the branch; when set, grants and Is public on this version also cover lower-ordinal versions of the same branch. Unique within the dataset and branch. |
| Buckets | The GCS buckets linked to this version (selected from the dataset's bucket list). Attaching a bucket to any version makes /gcs_token check access against those versions instead of the whole dataset. |
| Prefix | Optional path prefix within the bucket. Informational: tokens cover the whole bucket. |
| Is public | Grants anonymous callers and enabled human principals view access to this version and, when ordinals are set, its same-branch ancestors. It does not make the dataset-grain target public or grant write access. |
Terms are evaluated at the version a user requests. A version-grain TOS on a
public version does not follow is_public ancestry to that version's ancestors.
Use a dataset-grain TOS for a gated public release that must cover its ancestry
(or a service-and-dataset-scoped TOS when the gate is service-specific).
See Setting Up Dataset Versions for the procedure.
Map a service's own dataset names (and optionally versions) to DSG datasets and versions. Needed only when a service's vocabulary differs from DSG's, such as DVID node UUIDs or a neuPrint dataset named differently from DSG's.
| Field | Meaning |
|---|---|
| Service | The service whose requests are translated. |
| Client name | The dataset name that service sends. |
| Client version | The version string it sends. Blank for a name-level translation, which maps only the name; the version is then looked up among the target dataset's versions. |
| Dataset | The DSG dataset. |
| Dataset version | Required when Client version is set, blank otherwise; must belong to Dataset. |
Grants a permission to an entire group on a dataset. For example:
"the user group gets view on dataset fly-hemibrain."
All users in the group inherit the permission. This is the primary mechanism for broad access control. Use this for datasets that should be accessible to a whole community.
Direct per-user permission assignments. Each grant gives one user one permission on one dataset (optionally scoped to a specific version).
| Field | Meaning |
|---|---|
| User | The user receiving access. |
| Dataset | Which dataset. |
| Dataset version | If set, the grant applies to only this version. If blank, it applies to all versions. |
| Permission | view, edit, manage, or admin. |
| Group | If set, the grant is scoped to this group (created by a team lead). If blank, the grant is not group-scoped (created by an admin or via self-service). |
| Granted by | The admin or team lead who created this grant. |
| Source | manual (created by an admin or team lead via the web UI) or self_service (user accepted TOS on a public dataset). |
Grants are usually managed through the web UI at
/web/grants/<dataset>, which enforces authorization. Editing them
here is useful for bulk fixes or debugging.
Maps CAVE service/table pairs to datasets. Required for the
GET /api/v1/service/{namespace}/table/{table_id}/dataset endpoint.
Each service table can also have Public roots (inline), which are
root IDs that are publicly accessible without authentication.
Terms of Service documents that users must accept before their permissions take effect.
| Field | Meaning |
|---|---|
| Name | Display name (e.g., "FlyWire Terms of Use"). |
| Text | The full terms text (HTML is supported). |
| Dataset | The dataset this TOS applies to. |
| Dataset version | Optional — scope TOS to a specific version. |
| Invite token | Auto-generated unguessable token used in TOS landing page URLs (/web/tos/<token>/). You don't need to set this — it's generated automatically. |
| Effective date | When the TOS becomes active. |
| Retired date | If set, the TOS is no longer active after this date. |
Read-only record of which users accepted which TOS documents and when. Includes the IP address at the time of acceptance. You generally don't edit these — they're created automatically when users accept TOS via the web UI.
A record of administrative actions. The web UI, Django admin custom save
hooks, SCIM views, and import commands write entries through log_audit().
Use this table to inspect grant changes, TOS acceptances, service-account
mutations, SCIM changes, and bulk imports.
Authentication tokens. Each row links a token string to a user. Created automatically on OAuth login. Usually viewed inline on the User detail page. You can delete old/unused keys here if needed.
Non-human identities for programmatic access (CI jobs, backend services,
shared scripting credentials). Distinct from users — no Google login,
no TOS, no group membership. The admin console exposes the three models
for back-office visibility, but day-to-day management (create, mint
tokens, assign dataset grants, disable, delete) happens in the web UI
at /web/service-accounts and is admin-only.
See the dedicated Service Accounts doc for the full model, capabilities, limitations, and architectural decisions.
Required by django-allauth. There should be exactly one record with
id=1. Allauth uses this domain to construct OAuth callback URLs
(e.g., http://<domain>/accounts/google/login/callback/).
The historical migration sets this to localhost:8000, while the current
development server defaults to port 8200. If you configure allauth through
Django Site/SocialApp records instead of the settings-based Google provider,
update the Site domain to match your local or production origin:
bash scripts/manage.sh shell -c "
from django.contrib.sites.models import Site
Site.objects.update_or_create(id=1, defaults={'domain': 'auth.example.org', 'name': 'DatasetGateway'})
"The corresponding redirect URI must also be registered in your Google
Cloud Console OAuth client's Authorized redirect URIs (e.g.,
https://auth.example.org/accounts/google/login/callback/).
These are managed automatically by django-allauth during Google OAuth logins. You generally don't need to touch them.
- Social applications — the Google OAuth app configuration. If you
configured OAuth via environment variables or
client_credentials.json, this may be empty (allauth reads from Django settings instead). - Social accounts — links between DatasetGateway users and their Google accounts. Created automatically on first OAuth login.
- Social application tokens — OAuth tokens from Google. Managed automatically.
- Email addresses — email addresses associated with user accounts, managed by allauth. You generally don't need to edit these.
A dataset version is a named, static release of a dataset, such as
fish2 v0.7. Register one whenever a service asks DSG about a specific
version (neuPrint's fish2:v0.7, Clio's dataset:version) so that grants,
the public flag and TOS can be scoped to it. Field reference:
Dataset versions and
Dataset translations.
Every authorization request names a service, a dataset name, and optionally a
version. neuPrintHTTP, for example, sends fish2:v0.7 as name fish2 plus
version v0.7, and plain fish2 with no version. DSG resolves the pair in
this order (resolve_dataset_reference in core/authz.py):
- A Dataset translation for that service with the same name and version: its dataset version.
- A name-level translation for that service (no version): its dataset, then the version lookup in step 4 against that dataset.
- Otherwise, the Dataset whose Name equals the requested name.
- The version: the Dataset version whose Version equals the requested
string exactly. Failing that, an all-digit string is read as an ordinal
on the requested branch (default
main). Anything else does not resolve.
A request without a version targets the dataset as a whole (dataset grain).
An unregistered version is denied to everyone, admins included. When the
version does not resolve, POST /api/dsg/v1/authorize answers deny before
looking at any grant or public flag. The response looks like any other
denial; only DSG's decision log records the reason (unknown-translation).
Register the version before pointing a service at it.
Open Core › Datasets › (dataset) and add a row under Dataset versions (or use Core › Dataset versions › Add):
| Field | What to enter |
|---|---|
| Version | Exactly the string the service sends, e.g. v0.7. Case and punctuation matter. Unique within the dataset. |
| Branch | main for a linear series of releases. |
| Ordinal | Blank unless grants or the public flag on this version should also reach earlier versions (see below). Unique within the dataset and branch. |
| Buckets | Usually none; see Buckets. |
| Prefix | Optional note of where the version lives in its bucket. Not enforced: tokens always cover the whole bucket. |
| Is public | Leave unticked until the release is announced (see below). |
No grants are needed for existing users when their grants have a blank
Dataset version: a dataset-wide grant covers every version, including
ones added later. The import commands (import_neuprint_auth, import_csv)
create dataset-wide grants.
An ordinal places versions of one branch in order (for example 6 for
v0.6, 7 for v0.7). A grant scoped to a version, or that version's
Is public flag, covers:
| The version's Ordinal | Covers |
|---|---|
| Blank | Only that exact version. |
| Set | That version and every same-branch version with a lower or equal ordinal. |
Neither ever covers the dataset as a whole: a request for plain fish2 needs
a dataset-wide grant, or the dataset's Access mode set to Public. A
version without an ordinal is never covered by another version's reach.
Leave Ordinal blank by default. Set ordinals when a later release should
carry its audience back to earlier ones, for example so that making v0.7
public also publishes v0.6. Services whose Version eval mode is DAG
(intended for DVID) can receive service_eval decisions with ordinal anchors
for cross-branch questions; linear services (neuPrint, Clio) always get a
final answer.
Tick Is public on the version. Anonymous callers, enabled users and
service accounts then get view, never write, on that version (and, with an
ordinal, its ancestors), subject to TOS. The dataset can stay Closed: its
other versions and plain dataset-grain requests remain invite-only.
| Gate | Set up as | Applies to |
|---|---|---|
| Dataset-wide | TOS document with Dataset set, Dataset version and Service blank. Saving it makes it the dataset's Tos. | Every request for the dataset, any version |
| One version | TOS document with Dataset version set and Dataset blank | Requests that resolve to exactly that version |
| One service | TOS document with Dataset and Service set | That service's requests for the dataset |
Leave Dataset blank on a version TOS: a document with Dataset set and no Service is saved as the dataset's Tos, which gates every version.
A version TOS does not follow ordinal reach: publishing v0.7 with an
ordinal opens v0.6 without v0.7's version TOS. Use a dataset-wide TOS for
a gated release that must cover its ancestors.
Only when the service's names differ from DSG's. neuPrint and Clio usually
send the canonical dataset name and the version string, so most of their
datasets need none; an exception such as neuPrint's manc for DSG's MANC
needs a name-level translation. DVID sends node UUIDs, so fish2's DVID service
has translations mapping its UUIDs to fish2 and to fish2 v0.6. A
name-level translation (blank
Client version) renames the dataset but still needs a Dataset version
matching whatever version string the service sends.
/gcs_token decides by bucket, not by the version a viewer is looking at. For
each Dataset bucket row with the requested name:
- Attached to no version: access is checked against the dataset as a
whole. Dataset-wide grants and a
Publicdataset qualify; grants scoped to a version and public versions do not. - Attached to one or more versions: access is checked against each
attached version in turn, with the ordinal reach above. A grant on
v0.7with ordinals reaches a bucket attached tov0.6; a grant onv0.6does not reach a bucket attached tov0.7.
Tokens cover the whole bucket, so keep one audience per bucket (see How the token path works). When a new version's data goes into the same bucket, decide whether the bucket should stay attached to the old version, move to the new one, or be attached to no version. Detaching the last version switches that bucket back to dataset-wide checks.
- Add the
v0.7row (Versionv0.7, Branchmain, Ordinal blank). Do this before the service starts sendingfish2:v0.7. - Dataset-wide grants carry over. Grants scoped to
v0.6, and a TOS scoped tov0.6, do not: re-create them forv0.7if still wanted. - Review bucket attachments as described above.
- Retire
v0.6by unticking Is public (and setting Retired date on a version TOS, if any). Keep the row: services or old links may still ask forfish2:v0.6, and the row costs nothing.
Deleting a version row is permanent and cascades to its version-scoped grants, service-account grants, Dataset translations and TOS documents, and through those TOS documents to users' TOS acceptance records. The admin's delete confirmation page lists everything that will go; read it before confirming.
Anonymous (no token): only public versions return allow.
curl -s -X POST https://dsg.janelia.org/api/dsg/v1/authorize \
-H 'Content-Type: application/json' \
-d '{"service":"neuprint-fish2","entries":[{"name":"fish2","version":"v0.7"}]}'deny here means "not public" or "not registered"; the two look the same.
With a signed-in token (a user's or a service account's that can see the
dataset), list the registered rows:
curl -s -H "Authorization: Bearer $TOKEN" \
"https://dsg.janelia.org/api/dsg/v1/datasets/fish2/versions?service=neuprint-fish2"Each entry shows version, branch, ordinal and is_public. A user's own
decision for a version is the authorize call above with their token.
DatasetGateway is an ngauth
server: Neuroglancer can open a private GCS bucket through it, with DSG
deciding who may read and Google Cloud doing the actual serving. Getting a
bucket to work takes a one-time deployment step plus four per-bucket steps
(the CORS step is usually optional), two of which are on the GCP side and
cannot be done from the DSG admin console. This section is the checklist. Placeholders: GATEWAY_PROJECT is
the GCP project that holds DSG's own identity, BUCKET_PROJECT is the project
that owns the bucket (often a different one), BUCKET is the bare bucket
name, and https://viewer.example.org is an origin that embeds Neuroglancer.
Static web clients such as CODA can request a browser sign-in key through
https://dsg.janelia.org/login?origin=https%3A%2F%2Fnavis-org.github.io&token=api.
The user signs in to DSG, reviews the requesting site's name, owner, origin,
and the grant's expiry, then chooses Allow or Cancel. Allow is a
CSRF-protected POST; loading the page never creates a browser grant. Allow
posts {token: …} to the opener at the exact registered origin and closes
the popup. Cancel closes it without sending a message. The key is never
placed in a redirect URL.
To register a client, open CORE → Registered clients → Add in the Django admin and set:
- Name: the name shown in the consent page, for example
CODA. - Owner: the maintainer's contact text, for example
Philipp Schlegel. - Origin: the exact HTTP(S) origin, for example
https://navis-org.github.io, with no path, trailing slash, query, or fragment. A local development site can usehttp://localhost:<port>. - Enabled: checked to permit issuance and remembered-key delivery.
Matching is exact, not a regular expression. An empty registered-client
table disables API-mode sign-in, and a missing or disabled client gets the
"badorigin" popup message. The registration list is independent of
NGAUTH_ALLOWED_ORIGINS; that setting continues to govern Neuroglancer's
GCS-only temporary-token handshake without token=api. No environment
setting is needed for browser API sign-in. Allowed services is stored
as a JSON list but is read-only and reserved — not yet enforced.
Web origins have no path component. In particular,
https://navis-org.github.io covers every GitHub Pages site under the
navis-org organisation, not just /coda/. Register an origin only when
that entire origin is trusted to receive delegated credentials.
A delegated key expires after AUTH_COOKIE_AGE (7 days by default) and can
sign requests to neuPrint and other DSG-protected services using the user's
service permissions. It does not inherit global administrator authority:
identity and permission-cache responses report admin: false, and the
native and legacy authorization endpoints do not take the global-admin
shortcut. DSG refuses it at the four token-management endpoints, SCIM,
and web-cookie authentication. It also cannot be used as a cookie to
create or retrieve another browser grant. Per-service restriction is not
implemented; the key can reach all DSG services the user can access.
Don't ask again for this site stores a consent for that user and client. Later opens deliver the most recent live key for that client, updating its last-used time; they never mint another key. Once no live key remains, the consent page returns with the checkbox pre-ticked. Issuance purges expired keys for that user/client and keeps at most 10 live keys, evicting the oldest by creation time and ID. Other clients and the user's normal login and programmatic tokens are unaffected.
On My Account, Sites you have signed in from lists browser grants with their client, origin, creation time, expiry, and a Revoke action. Remembered sites → Forget this site restores the consent prompt while leaving issued keys valid. Revoking a key likewise leaves remembered consent intact. Users can revoke or forget only their own entries.
To stop new issuance and remembered delivery, untick the client's
Enabled field; this takes effect on the next request. It does not
revoke issued keys. Revoke those separately from the user's account page,
or delete the client's API key rows in the admin. Disable rather than
delete a registration while keys remain: the foreign key uses SET_NULL,
so deleting the registration would remove their delegated-client marker.
Delete its keys before deleting the registration itself.
DSG rejects revoked keys immediately. neuPrintHTTP can continue accepting
a previously validated identity until its cache expires (300 seconds by
default), so allow that interval for revocation to reach cached requests.
Consent, delivery, cancel, badorigin, and login pages in API mode carry
Cache-Control: no-store, private, Referrer-Policy: same-origin,
X-Frame-Options: DENY, and Cross-Origin-Opener-Policy: unsafe-none.
The referrer policy is deliberately same-origin rather than no-referrer:
browsers send Origin: null on a form POST from a no-referrer document,
and Django's CSRF origin check would then refuse the consent form.
A Neuroglancer layer source such as
precomputed://gs+ngauth+https://dataset-gateway.mydomain.org/BUCKET/path/to/volume
zarr3://gs+ngauth+https://dataset-gateway.mydomain.org/BUCKET/
names the DSG host and the bucket. The client then:
- Opens
/login?origin=<viewer origin>in a popup. The user signs in with Google and the popup posts a short-lived user token back to the viewer. (POST /tokenis the cookie-based fallback for same-site callers.) - Posts
{token, bucket}to/gcs_token. DSG resolves the bucket name to its Dataset bucket rows, authorizes the user from the dataset / grant / TOS model (see Datasets; the check runs with no service scope, so service-scoped grants do not count), and, if authorized, mints a GCS access token from DSG's own GCP identity, downscoped via the Security Token Service toroles/storage.objectVieweron that one bucket for about an hour. - Fetches objects directly from
storage.googleapis.comwith that token. DSG is not in the data path.
DSG never adds users to bucket IAM. (Earlier releases also synced per-user
roles/storage.objectViewer bindings; that subsystem was retired in
September 2026 and migration 0017 drops its bucket_iam_binding ledger.
Any user binding still present on a bucket was placed by hand or by that old
code and is not removed automatically.) Earlier releases still query that
table, so apply 0017 only while no older server process is running
(git pull, then pixi run stop-serve && pixi run python manage.py migrate && pixi run serve-bg), and before rolling back to an earlier release run
pixi run python manage.py migrate core 0016 from this one; it recreates the
empty table.
Three consequences drive the steps below:
- DSG's runtime identity must itself be able to read the bucket. A
downscoped token can never exceed what the identity holds, so if the
identity has no binding on the bucket, DSG happily issues a token that GCS
then rejects. The DSG log shows
decision=issued; the viewer shows an authentication or permission error fromstorage.googleapis.com. - Issuance is whole-bucket. The token reads every object in the bucket, so a bucket has exactly one audience: everyone authorized for any dataset the bucket is attached to. Attaching one bucket to several datasets means the union of their audiences. Never mix public and restricted data in one bucket; use a separate bucket instead.
- The browser talks to GCS cross-origin. Neuroglancer fetches through the GCS JSON API, which answers CORS for any origin, so bucket CORS configuration is not needed for Neuroglancer itself. It matters only if some other browser client reads the bucket through XML-API URLs.
DSG uses Google Application Default Credentials (ADC) for its only GCP call,
token minting. This identity is unrelated to the OAuth client used for login;
both may live in secrets/ but they do different jobs.
-
Create a dedicated service account in the gateway project. Grant it no project-level roles; it gets per-bucket bindings only.
gcloud iam service-accounts create dsg-ngauth --project=GATEWAY_PROJECT \ --display-name="DatasetGateway ngauth runtime" -
Make it DSG's ADC. On a plain host, download a JSON key into
secrets/(mode 0600; the directory is git-ignored) and point.envat it:gcloud iam service-accounts keys create secrets/dsg-ngauth-key.json \ --iam-account=dsg-ngauth@GATEWAY_PROJECT.iam.gserviceaccount.com chmod 600 secrets/dsg-ngauth-key.json echo 'GOOGLE_APPLICATION_CREDENTIALS=secrets/dsg-ngauth-key.json' >> .env
Restart DSG to pick up the variable. On GCE/GKE, attach the service account to the workload instead of shipping a key. Without a usable ADC,
/gcs_tokenanswers503 Credential service unavailable. -
Allow the viewer origins.
NGAUTH_ALLOWED_ORIGINS(see Environment Variables Reference) is a regex that must fully match each embedding origin, e.g.^https://(viewer|viewer-dev)\.example\.org$. A reverse proxy in front of DSG must not add its ownAccess-Control-Allow-Originheader on the ngauth endpoints; DSG emits the correct one itself.
DSG serves Cross-Origin-Opener-Policy: unsafe-none on /login, /auth/login,
and every response under /accounts/, including redirects and error pages,
so the login popup keeps its opener through the whole Google round trip.
Everything else keeps same-origin. A reverse proxy must not add or replace
Cross-Origin-Opener-Policy on those paths.
Before the runtime identity is granted on a bucket, confirm that
everything in it is meant for that bucket's whole (union) audience.
Listing the bucket and recording the approval in your rollout notes is
enough. Also confirm the bucket does not have Requester Pays enabled: DSG
sends no userProject, so a Requester Pays bucket cannot be served.
In the admin console, open (or create) the Dataset and add a Dataset
bucket whose name is the bare bucket name (BUCKET, no gs://). If access
is version-scoped, attach the bucket to the relevant Dataset versions too.
Then make sure the intended users are covered:
- a
GrantorGroup dataset permissionwith a blank Service field, or the dataset's Access mode set toPublic(or a version marked Is public); and - the dataset's TOS document, if any, accepted by each user.
/gcs_tokenreturnstos_requiredwith atos_urlthe viewer can open when acceptance is missing.
Registering first is safe: until step 4 the runtime identity cannot read the bucket, so no data is reachable yet.
Neuroglancer's gs+ngauth sources fetch through the JSON API
(storage.googleapis.com/storage/v1/b/BUCKET/o/OBJECT?alt=media), and GCS
answers CORS for any origin on that endpoint regardless of bucket
configuration. Verified: a bucket whose CORS listed only two origins still
served a third origin's JSON-API preflight and GET with a matching
Access-Control-Allow-Origin. Bucket CORS configuration applies to the XML
API (storage.googleapis.com/BUCKET/OBJECT), so set it only when some
browser client reads the bucket that way. Skip this step for a
Neuroglancer-only bucket.
cat > cors.json <<'JSON'
[{"origin": ["https://viewer.example.org"],
"method": ["GET", "HEAD"],
"responseHeader": ["Content-Type", "Range", "Authorization"],
"maxAgeSeconds": 3600}]
JSON
gcloud storage buckets update gs://BUCKET --cors-file=cors.json
gcloud storage buckets describe gs://BUCKET --format="json(cors_config)"List every origin that reads the bucket through the XML API. Range matters
because chunked formats issue range reads.
Custom roles are project-scoped, so create the role once per bucket project
and reuse it for later buckets there. The role is read-only and deliberately
omits object create/delete and storage.buckets.setIamPolicy: the token
path never writes IAM, and an identity that can rewrite bucket policy is a
much larger blast radius than a read-serving gateway needs.
gcloud iam roles create dsgNgauthBucketManager --project=BUCKET_PROJECT \
--title="DSG ngauth bucket manager" \
--description="DatasetGateway ngauth runtime: read bucket metadata and objects. No create, delete, or setIamPolicy." \
--permissions=storage.buckets.get,storage.objects.get,storage.objects.list \
--stage=GA
gcloud storage buckets add-iam-policy-binding gs://BUCKET \
--member=serviceAccount:dsg-ngauth@GATEWAY_PROJECT.iam.gserviceaccount.com \
--role=projects/BUCKET_PROJECT/roles/dsgNgauthBucketManagerNotes:
- A freshly created custom role can take a minute to propagate. If the binding fails with "Role … does not exist in the resource's hierarchy", retry unchanged.
- The predefined
roles/storage.objectVieweralso works for the token path. Roles created before September 2026 also carrystorage.buckets.getIamPolicy, which only the retired per-user IAM reconcile used; it can be dropped withgcloud iam roles update dsgNgauthBucketManager --project=BUCKET_PROJECT --remove-permissions=storage.buckets.getIamPolicy. - The binding is compatible with uniform bucket-level access and enforced public access prevention; a bucket-level grant to a service account is not public access.
- If the bucket's organization restricts sharing to specific domains, the
gateway project must be inside an allowed organization. A successful
add-iam-policy-bindingconfirms this.
Every /gcs_token decision is logged at INFO regardless of DSG_LOG_LEVEL
(dsg/serve.log for pixi run serve-bg, otherwise the service journal):
GCS token decision user=EMAIL bucket=BUCKET decision=issued reason=covered
reason is covered (a grant or group permission), public, or
public-version. Then open the layer in the viewer: first load should
prompt the ngauth login popup, after which chunks render.
| Symptom | Log reason / response |
Cause and fix |
|---|---|---|
Popup shows badorigin; /token or /gcs_token returns 403 Origin not allowed |
— | Viewer origin does not fully match NGAUTH_ALLOWED_ORIGINS. Fix the regex and restart. |
403 Access denied |
unknown_bucket |
No Dataset bucket row has this exact name. Register it (step 2). |
403 Access denied |
no_coverage |
User has no qualifying grant, group permission, or public coverage. Check the grant's Service field is blank. |
403 tos_required |
missing_tos |
User has not accepted the dataset's TOS. Send them the returned tos_url. |
503 Credential service unavailable |
adc_unavailable |
GOOGLE_APPLICATION_CREDENTIALS unset, unreadable, or not a service-account key. |
502 Credential exchange failed |
sts_response_error |
STS rejected the exchange. Check the key is current and the service account is not disabled. |
decision=issued, but the viewer reports an authentication or permission error from storage.googleapis.com |
issued |
The runtime identity is not granted on the bucket. Do step 4 and check gcloud storage buckets get-iam-policy gs://BUCKET. |
decision=issued, browser console shows a CORS error on storage.googleapis.com |
issued |
Only possible for clients using XML-API URLs (storage.googleapis.com/BUCKET/…); Neuroglancer's JSON-API fetches are exempt from bucket CORS. Add the origin and the Range/Authorization response headers in step 3. |
decision=issued, layer shows …/info not found … HTTP error 404 (or …/zarr.json … 404) |
issued |
Auth is working: GCS answers 404 only to a caller that can read the bucket. The layer scheme does not match the data format. Use precomputed:// for a volume that has an info file and zarr3:// (or zarr://, which auto-detects) for a Zarr volume that has zarr.json; confirm the path with gcloud storage ls gs://BUCKET/PATH. |
| Variable | Default | Purpose |
|---|---|---|
DJANGO_SECRET_KEY |
insecure dev key | Session signing. Must be set in production. |
DJANGO_DEBUG |
True |
Debug mode. Set to False in production. |
DJANGO_ALLOWED_HOSTS |
* |
Comma-separated allowed hostnames. Must be set in production. |
DATABASE_PATH |
db.sqlite3 |
Path to the SQLite database file. |
GOOGLE_CLIENT_ID |
(empty) | Google OAuth client ID. |
GOOGLE_CLIENT_SECRET |
(empty) | Google OAuth client secret. |
CLIENT_CREDENTIALS_PATH |
secrets/client_credentials.json |
Alternative path to OAuth credentials file. In Docker, mount this file or use GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET. |
AUTH_COOKIE_DOMAIN |
(empty) | Set to .example.org to share the dsg_token cookie across subdomains. |
NGAUTH_ALLOWED_ORIGINS |
^https?://.*\.neuroglancer\.org$ |
Regex for allowed CORS origins on ngauth endpoints. |
GOOGLE_APPLICATION_CREDENTIALS |
(empty; Google ADC default chain) | Path to the service-account key DSG uses as its own GCP identity for ngauth token minting. See Neuroglancer (ngauth) Bucket Setup. |
TOS_RETURN_ALLOWED_ORIGINS |
(empty) | Comma-separated exact HTTP(S) origins allowed as /web/tos/service-check/ return targets. Origins accepted by NGAUTH_ALLOWED_ORIGINS are also valid returns; adding an origin here does not grant ngauth CORS access. |
DSG_ORIGIN |
(empty) | Public origin for CSRF trusted origins (e.g., https://dataset-gateway.mydomain.org). |
DSG_PORT |
8200 |
Port for the development server. |
SECURE_SSL_REDIRECT |
True (when not DEBUG) |
Whether to redirect HTTP to HTTPS. |
All commands are run from the dsg/ directory. Use bash scripts/manage.sh
instead of python manage.py to auto-detect whether to run locally or
inside a Docker container.
| Command | Purpose |
|---|---|
pixi run make-admin EMAIL |
Create or promote a user to admin (works for both local and Docker). |
pixi run make-admin EMAIL --remove |
Revoke admin status from a user. |
bash scripts/manage.sh migrate |
Create/update database tables. |
bash scripts/manage.sh createcachetable dsg_cache_table |
Manually create the shared permission-cache table if migration fallback is needed. |
bash scripts/manage.sh changepassword EMAIL |
Reset a user's admin console password. |
bash scripts/manage.sh seed_permissions |
Create view, edit, manage, and admin permission types. |
bash scripts/manage.sh seed_groups |
Create default groups (admin, sc, team_lead, user). |
bash scripts/manage.sh import_csv FILE --dataset DS |
Import users from CSV and grant view on one dataset. |
bash scripts/manage.sh import_clio_auth FILE |
Import users, datasets, and grants from a Clio export JSON. Note: --dry-run is currently not a no-write preview. |
bash scripts/manage.sh import_neuprint_auth FILE --datasets DS [DS ...] |
Import neuPrint authorized.json. |
pixi run setup |
Interactive setup wizard — generates .env. |
pixi run serve |
Start the development server (runs setup if .env is missing). |
pixi run serve-bg |
Start the dev server detached; logs to dsg/serve.log, PID in dsg/serve.pid. |
pixi run deploy |
Build and deploy with Docker. |
pixi run stop-deploy |
Stop the Docker deployment. |
pixi run stop-serve |
Stop the detached development server (kills serve.pid, cleans up). |