Skip to content

Add organization API keys and an organization-scoped v1 API - #800

Merged
payamnj merged 5 commits into
masterfrom
feature/organization-api-keys
Aug 7, 2026
Merged

Add organization API keys and an organization-scoped v1 API#800
payamnj merged 5 commits into
masterfrom
feature/organization-api-keys

Conversation

@payamnj

@payamnj payamnj commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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 explicit key_type column, plus a CheckConstraint tying it to the presence of an organization, makes the platform check key_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 exp of datetime.max and carried no claim that wasn't already the credential. Its only real function was smuggling the row's salt so 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>, where key_id is 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_hash from 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 the key/salt columns are gone.

Keys can no longer be read back (breaking). Both key screens now show key_id and 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 (rotated ENCRYPTION_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 the enrollments:create scope. 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 404 rather than 403, so a key holder can't probe which other organizations exist. Rate limiting is keyed on key_id rather 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 the key_type constraint 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 ApiKeyScope through 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) and can_delete_organization_api_key(request, organization) decide what the API allows. Both default to True and reject with 403 before any database work, following the existing can_create_course hook pattern. They take the resolved Organization rather 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 403 against 404.

OpenAPI schema

GET /api/v1/openapi.json serves an OpenAPI 3.1 document, validated against openapi-spec-validator as a one-off. No new dependency — django-ninja was 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 own NinjaAPI, and coupling this library's Django support window to ninja's).

Three things are read from the implementation rather than restated:

  • Paths from the routed URLconf, including the mount prefix — so they follow wherever a consuming project mounts the app
  • Schemas from the Pydantic models the views validate with; Pydantic v2 emits JSON Schema 2020-12, which is what OpenAPI 3.1 consumes
  • Security from the scopes the auth decorator enforces, which it now publishes — so the documented scope is the checked scope

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

  • No rendered docs page. The JSON is what tooling consumes; point Swagger UI, Redoc or Postman at it.
  • No "skip verification" flag on enrollment. A partner enrolling from its own signup flow arguably has consent already, but adding a way to put someone on an email course without confirming the address is a deliberate policy call, not something to slip into a new API. Easy to add later; breaking to remove.

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

payamnj and others added 5 commits August 7, 2026 18:28
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>
@payamnj
payamnj merged commit e8ca819 into master Aug 7, 2026
13 checks passed
@payamnj
payamnj deleted the feature/organization-api-keys branch August 7, 2026 17:35
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.

1 participant