Repository navigation
🔑 feat: revocable API keys with scopes - #303
Merged
Merged
Conversation
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.
Docs previewNew docs deployment: [ |
erick-GeGe
force-pushed
the
feat/api-keys
branch
from
September 14, 2026 15:25
947fc77 to
f888c7c
Compare
`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.
This was referenced Sep 16, 2026
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.
joaquingx
approved these changes
Sep 28, 2026
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.
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-bmcalready 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:
datarunmanagemanageis 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 omitsexpires_in_daysgets 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 withestela_, falling through toTokenAuthenticationotherwise. DRF tokens keep working untouched, and existing consumers change a value, not their code.A bug this would have introduced
api/views/job.pydidrequest.auth.keyon the synchronous job path. With a key,request.authis anApiKey, which has no.key— a 500 on job creation, which is exactly what one of the DAGs does. It now goes throughget_job_callback_token, which falls back to the project owner's token, the same one queued jobs already use.core.tasksshares that helper instead of keeping a private copy.Three things that look like dead weight and are not
ApiKeyCreateResponseSerializernever serializes anything. It exists so Swagger documents that creation also returns the key; without it the docs omit the one field that matters.ApiKeyViewSetlistsApiKeyAuthenticationonly to haveIsSessionAuthenticatedrefuse it. Dropping both would still give 401, but the message would read "Invalid token" instead of "This action requires a login session".ApiKeySerializerdeclaresscopesexplicitly. A bareJSONFielddocuments as a string, and the generated TS client then types itstringinstead ofstring[].Verified
Against a local deployment, one key per scope:
Expiry:
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.yamland the generated TS client are regenerated.make docsfails locally whenbitmaker_billingis inDJANGO_EXTERNAL_APPS— it andapi.serializersshare three serializer names and drf_yasg refuses to build the schema. Generating withDJANGO_EXTERNAL_APPS=works and needs no code change.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 jobcallsPUT .../jobs/{jid}/stop, a route the API does not have → 404. Stopping a job isPUT .../jobs/{jid}withstatus: STOPPED.JOB_NOT_STOPPEDhas three placeholders and is filled with a two-item list, soIndexErrorreplaces the validation message.