Add organization API keys and an organization-scoped v1 API - #800
Merged
Conversation
Organization admins can now issue scoped API keys for their own organization and use them against a new /api/v1/ surface covering enrollment creation, enrollment listing and course listing. One ApiKey model serves both kinds rather than two near-identical models: they share generation, verification, expiry, revocation and listing, and the only real difference is a single field. The kinds are told apart by an explicit key_type column with a database check constraint tying it to the presence of an organization, so a dropped filter cannot silently produce a key with deployment-wide authority. Views take the organization from the key rather than from the URL or body, so a key can only ever act on the organization it was issued for. Storage moves from reversible Fernet encryption to a SHA-256 hash of a 256-bit secret, with the token shaped elk_<key_id>_<secret>. The non-secret key_id makes verification one indexed lookup instead of decrypting every candidate row, and replaces the JWT wrapper whose only real function was carrying the row's salt for that lookup. Existing keys keep working: a data migration derives the hash from the stored ciphertext, and legacy JWTs resolve through the same lookup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The v1 API only needs enrollment creation for now, so the scope enumeration drops to a single member. The two read endpoints go with it rather than being left guarded by a create scope, which would grant reads on the strength of a write permission. Organization keys must now carry at least one scope. Every organization endpoint requires one, so a scopeless key is a credential that authenticates and can then do nothing; rejecting it at creation beats issuing something that 403s on every call. Enforced in clean() rather than as a database constraint - unlike the key_type/organization constraint, whose failure mode is a key gaining deployment-wide authority, this one fails closed, so a backstop below the model buys hygiene rather than safety. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Serves GET /api/v1/openapi.json, built by reading the implementation rather than by maintaining a parallel description of it: - paths come from the URLconf that is actually routed, including the mount prefix the including project chose, so a library consumer who mounts the app elsewhere gets correct paths - request and response schemas come from the Pydantic models the views validate and serialise with; Pydantic v2 emits JSON Schema 2020-12, which is what OpenAPI 3.1 consumes, so nothing is translated - security requirements come from the scopes the auth decorator enforces, which it now publishes, so the documented scope and the checked scope are the same value Only the prose and the status-code map are written by hand. A test fails if a routed endpoint has no OperationSpec, and a second test proves that guard can actually fire, so an endpoint added without documentation breaks the build instead of shipping a partial document. Output was checked against openapi-spec-validator as a one-off; the validator is deliberately not added as a dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Organization admins can now issue, review and revoke their own keys from the UI rather than only over HTTP. The table follows the platform API keys screen — key id, status, created by, created at, last used — plus the scopes each key carries, which platform keys don't have. The available scopes come from ApiKeyScope via the page context rather than being listed again in the frontend, so the choices the form offers cannot drift from the scopes the API will accept. As on the platform screen there is no way to reveal a key after the fact: the secret is stored hashed, so the token appears once in a dialog at creation and the listing shows only the public key_id. Revoking asks for confirmation and leaves the row in place, and a revoked key offers no further action. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds PlatformFeature.ORGANIZATION_API, included in the default feature set so it reaches the frontend through the existing availableFeatures context. The organization page's API Keys tab, and the request that loads the keys, are both gated on it. The flag governs the UI only. For the operations themselves, OrganizationApiKeyView and SingleOrganizationApiKeyView each gain an overridable hook - can_create_organization_api_key and can_delete_organization_api_key - defaulting to True and rejecting with 403 before any database work, matching the existing can_create_course pattern. Both take the request and the resolved Organization rather than an id, so a plan-limit check can read the organization's state without a second query. The delete hook runs before the key is looked up, so a caller who may not revoke can't tell a real key id from a made-up one by comparing 403 against 404. Also switches the empty API keys table to the shared EmptyTableState component used by the courses, learners and platform API key tables. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Organization admins can now issue scoped API keys for their own organization from a new tab on the organization page, and use them against a new
/api/v1/surface with an OpenAPI schema generated from the code that serves it.Answers the design question from the discussion that preceded this: one model, expanded — not a second model.
One model, or two?
Platform and organization keys share ~90% of their machinery: generation, verification, expiry, revocation, listing, rate limiting,
last_used_at. Splitting them means writing that twice and fixing every crypto bug twice. The scope of what a key may do is authorization data, not a reason for a separate table.The one real argument for two models was avoiding a fail-open — if "platform key" is encoded as
organization IS NULL, any bug that drops the org filter silently yields a global-privilege key. So it isn't encoded that way. An explicitkey_typecolumn, plus aCheckConstrainttying it to the presence of an organization, makes the platform checkkey_type == PLATFORM: an affirmative assertion a forgotten filter can't accidentally satisfy, and the DB refuses to store an incoherent row.Dropping the JWT and the reversible encryption
The JWT was signed with a fixed
expofdatetime.maxand carried no claim that wasn't already the credential. Its only real function was smuggling the row'ssaltso verification could narrow the "decrypt every candidate row and compare plaintext" lookup — a lookup index wearing a security costume.Keys are now a SHA-256 hash of a 256-bit random secret. Token format is
elk_<key_id>_<secret>, wherekey_idis a non-secret public identifier making verification a single indexed lookup. Plain SHA-256 rather than a slow KDF: the secret is 256 bits of randomness, not a human password, so stretching would only add latency to every request.Handing an org admin a self-issued credential is a much wider exposure than one internal platform key, so "we can decrypt every customer's API key from the database" isn't a property to carry forward.
Upgrade path
Existing keys keep working. A data migration derives
key_id/secret_hashfrom the stored ciphertext, and legacy JWTs resolve through the same hash — both formats converge on one code path. Verified end-to-end against a database seeded at 0016 with a real Fernet-encrypted key, migrated forward: the legacy JWT still authenticates and thekey/saltcolumns are gone.Keys can no longer be read back (breaking). Both key screens now show
key_idand status rather than the key itself; the token appears once, in a dialog at creation. An operator who lost a key issues a replacement. A key whose ciphertext won't decrypt (rotatedENCRYPTION_SECRET_KEY) gets a hash no token can match — fail closed rather than deleting the row, which would silently widen access if that key was the only thing gating an endpoint.The v1 API
One endpoint:
POST /api/v1/enrollments/, requiring theenrollments:createscope. Mounted separately from/api/public/, which is the unauthenticated embed-token surface for third-party pages — bolting an authenticated partner API onto it would get the two trust models confused.The organization comes from the key, never from the URL or body; a slug belonging to another organization reads as
404rather than403, so a key holder can't probe which other organizations exist. Rate limiting is keyed onkey_idrather than client IP, since a server-to-server caller may sit behind a shared egress address.Organization keys must carry at least one scope — enforced in
clean()rather than as a DB constraint, since unlike thekey_typeconstraint this one fails closed (a scopeless key authenticates and can then do nothing), so a backstop below the model buys hygiene rather than safety.Managing keys
A new API Keys tab on the organization page, after Newsletters. The table follows the platform API keys screen — key id, status, created by, created at, last used — plus the scopes each key carries. The scope checkboxes come from
ApiKeyScopethrough the page context rather than being listed again in the frontend, so the form can't offer a scope the API would reject, and a new scope appears with no frontend change.Two controls, with two jobs:
PlatformFeature.ORGANIZATION_API(present by default) decides whether the UI offers the tab.can_create_organization_api_key(request, organization)andcan_delete_organization_api_key(request, organization)decide what the API allows. Both default toTrueand reject with403before any database work, following the existingcan_create_coursehook pattern. They take the resolvedOrganizationrather than an id, so a plan-limit check needs no second query.The delete hook runs before the key lookup, so a caller who may not revoke can't distinguish a real key id from a made-up one by comparing
403against404.OpenAPI schema
GET /api/v1/openapi.jsonserves an OpenAPI 3.1 document, validated againstopenapi-spec-validatoras a one-off. No new dependency —django-ninjawas considered and rejected for now: it's a view-layer commitment, and forcing it on every consumer of a library with six runtime dependencies is a real imposition (namespace collisions with consumers running their ownNinjaAPI, and coupling this library's Django support window to ninja's).Three things are read from the implementation rather than restated:
Two tests do the real work: one fails the build if a routed endpoint has no spec, and a second monkeypatches the spec away to prove that guard actually fires.
Not included
Testing
1094 backend + 379 frontend tests pass. New coverage: model constraints and token round-tripping, legacy JWT back-compat, scope enforcement, cross-org isolation, expiry/revocation, both key types rejected from each other's surface, per-key rate limiting, the OpenAPI generator, the permission hooks (including the no-leak ordering on delete), and the tab's create/revoke flows and feature gating.
🤖 Generated with Claude Code