Skip to content

🔑 feat: revocable API keys with scopes - #303

Merged
erick-GeGe merged 5 commits into
mainfrom
feat/api-keys
Sep 28, 2026
Merged

erick-GeGe merged 5 commits into
mainfrom
feat/api-keys

Conversation

@erick-GeGe

@erick-GeGe erick-GeGe commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Why

A user has exactly one credential today: the DRF token, which is at once the browser session, the CLI login and whatever external service was handed it. It cannot be rotated without logging the person out, it never expires, and it carries everything that person can do.

Two DAGs in dags-bmc already consume estela this way, each holding somebody's personal token in an Airflow Variable.

What

Keys a user issues per program, from Settings → API keys. Only the SHA-256 is stored, so the key is shown once at creation and never again. If it leaks, revoking that one key leaves the session and every other key working.

Scopes

Additive on top of a read floor, and none implies another:

The key can
(none) list projects, spiders, jobs, cronjobs and deploys
data …plus read items, logs, requests and stats
run …plus launch and stop jobs and cronjobs
manage …plus deploy code, and create, edit or delete projects

manage is named for what it does — change the project itself, as opposed to operating inside it. Project deletion is soft (deleted = True), so a key cannot destroy anything irreversibly.

Keys are refused outright on the endpoints that manage keys — a leaked key must not mint more — and on password changes.

Expiry

Four durations: 7, 30, 90 days and a year, defaulting to 90. No custom value, and no "never".

The default lives only in API_KEY_DEFAULT_DAYS. A request that omits expires_in_days gets whatever that setting says, so changing it changes what every caller receives without touching any of them. The table shows the expiry date, red in the last week and once it has passed.

Compatibility

Authentication reuses the existing Authorization: Token ... header and only claims credentials starting with estela_, falling through to TokenAuthentication otherwise. DRF tokens keep working untouched, and existing consumers change a value, not their code.

A bug this would have introduced

api/views/job.py did request.auth.key on the synchronous job path. With a key, request.auth is an ApiKey, which has no .key — a 500 on job creation, which is exactly what one of the DAGs does. It now goes through get_job_callback_token, which falls back to the project owner's token, the same one queued jobs already use. core.tasks shares that helper instead of keeping a private copy.

Three things that look like dead weight and are not

  • ApiKeyCreateResponseSerializer never serializes anything. It exists so Swagger documents that creation also returns the key; without it the docs omit the one field that matters.
  • ApiKeyViewSet lists ApiKeyAuthentication only to have IsSessionAuthenticated refuse it. Dropping both would still give 401, but the message would read "Invalid token" instead of "This action requires a login session".
  • ApiKeySerializer declares scopes explicitly. A bare JSONField documents as a string, and the generated TS client then types it string instead of string[].

Verified

Against a local deployment, one key per scope:

read-only +data +run +manage
list jobs and cronjobs 200 200 200 200
read job data 403 200 403 403
create job 403 403 201 403
create cronjob 403 403 201 403
create project 403 403 403 201
deploy 403 403 403 passes the check
list or create API keys 403 403 403 403

Expiry:

  • omitting the duration lands 90 days out, inheriting the setting
  • asking for 7 lands 7 days out; asking for 45 is rejected as an invalid choice
  • an expired key gives 401 {"detail": "This API key expired on 2026-09-13."}

Plus: a revoked key gives 401, an unknown estela_ key gives 401, and existing DRF tokens still pass everywhere.

The script that produces the scope table lives outside this repo, at scripts/apikeys-estela/test_keys.py.

Notes

  • docs/api.yaml and the generated TS client are regenerated. make docs fails locally when bitmaker_billing is in DJANGO_EXTERNAL_APPS — it and api.serializers share three serializer names and drf_yasg refuses to build the schema. Generating with DJANGO_EXTERNAL_APPS= works and needs no code change.
  • No tests, by request. The tables above were checked by hand.
  • The estela-cli side (set-host, login <key>, the prefilled page behind ?cli=1) is a separate PR stacked on this one.

Found along the way, not fixed here

  • estela stop job calls PUT .../jobs/{jid}/stop, a route the API does not have → 404. Stopping a job is PUT .../jobs/{jid} with status: STOPPED.
  • That update then 500s: JOB_NOT_STOPPED has three placeholders and is filled with a two-item list, so IndexError replaces the validation message.

Today a user has exactly one credential: the DRF token that is at once the
browser session, the CLI login and whatever external service was handed it.
It cannot be rotated without logging the person out, and it carries everything
that person can do.

This adds keys a user issues per program, from Settings > API keys. Only the
SHA-256 is stored, so the key is shown once at creation and never again; if it
leaks, revoking that one key leaves the session and every other key working.

Scopes are additive on top of a read floor. Every key can list projects,
spiders and jobs; `data` adds reading job data, `run` adds launching jobs, and
neither implies the other. `deploy` exists and is enforced but is not offered
in the UI yet, so the CLI work can use it without migrating issued keys.

Authentication reuses the existing `Authorization: Token ...` header and only
claims credentials starting with `estela_`, so DRF tokens keep working
untouched and existing consumers change a value, not their code. Keys are
refused outright on the endpoints that manage keys and on password changes.

Also swapped `request.auth.key` in the synchronous job path for a helper: with
a key, `request.auth` is an ApiKey and has no `.key`, which would have 500'd
on job creation. The helper falls back to the project owner's token, the same
one queued jobs already use, and `core.tasks` now shares it instead of keeping
a private copy.
@github-actions

Copy link
Copy Markdown

Docs preview

New docs deployment: [2b9bc4636040113b4d81cab7d5fc79b769727b35]!
Check out the docs preview here 👈

`manage` is what the scope actually describes: changing the project itself —
its code, its settings and its existence. Deploying replaces a project's code,
and creating or deleting one is the same family; operating inside a project is
`run`. So ProjectViewSet now sits behind it too, which is what `estela create
project` and `estela delete project` need. Project deletion is soft, so a key
cannot destroy anything irreversibly.

SpiderCronJobViewSet declared no scope, so it fell to the default and no key
could create or edit a cronjob. A cronjob is a job with a schedule, and a `run`
key can already launch jobs in a loop, so it belongs there.

Renaming now costs one constant: `deploy` was never offered in the UI, so no
key carries it. After keys are issued it would cost a migration.
Keys lasted forever, which is the flaw the feature was meant to fix — a key
that leaks and goes unnoticed stays valid until someone thinks to revoke it.

Four durations: 7, 30, 90 days and a year, defaulting to 90. No custom value,
because a custom range would quietly move the cap the choices establish, and no
"never".

The default lives only in `API_KEY_DEFAULT_DAYS`. A request that *omits*
`expires_in_days` gets whatever that setting says, so changing it changes what
every caller receives without touching any of them — which is how estela-cli
will inherit it.

`expires_at` is nullable for the keys issued before this column existed; the UI
never offers that, so every new key expires. The 401 names the date rather than
saying something generic, since "expired" and "revoked" call for different
fixes.
The create form now carries all three scopes and a duration, and the table
shows when each key runs out — red in the last week and once it has passed,
since a key that dies unannounced is an outage.

The scopes stay independent checkboxes. Ticking `run` does not tick `data`:
they are unrelated, and coupling them would hand a key permissions nobody asked
for, which is the opposite of the point.

Regenerates docs/api.yaml and the TS client.
0044 only added a column to the table 0043 had just created, in the same pull
request. Neither has reached production, so there is no history to preserve —
the column belongs in the CreateModel.

Anyone who applied both (staging, local) needs to drop core_apikey and delete
the two rows from django_migrations before migrating again. The keys there are
test keys.
@erick-GeGe
erick-GeGe merged commit f03fa20 into main Sep 28, 2026
1 check passed
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