UN-4016 [FEAT] Resolve the organisation from a platform API key via a whoami endpoint - #2269
UN-4016 [FEAT] Resolve the organisation from a platform API key via a whoami endpoint#2269pk-zipstack wants to merge 6 commits into
Conversation
… whoami endpoint
A caller holding a platform API key knows the secret but not what it is scoped
to: `organization_id` is only discoverable by reading it out of a web-app URL,
and every organisation-scoped endpoint takes it as a path segment. This adds
`GET /api/v1/unstract/whoami/`, which returns `{organization_id,
organization_name, permission, key_name}` read off the key row itself.
The route carries no organisation segment, and that is what made it more than a
view. `OrganizationMiddleware` matches `^/api/v1/unstract/<org>/`, so it parsed
`whoami` as the organisation and rewrote the path to a 404. Its whitelist escape
hatch then returned without ever setting `request.organization_id`, which
`CustomAuthMiddleware` reads by bare attribute access -- a 500 rather than a
skip. Both are fixed: the path is whitelisted for the organisation middleware
only (it still authenticates), and the whitelist branch now sets the attribute,
which protects any future organisation-less path.
The endpoint also flows through the committed OpenAPI spec, which needed three
gates widened: the published-prefix check now accepts the tenant mount as well
as the deployment one, `SPEC_URLCONFS` gains the new urlconf, and the two spec
tests that looped over every operation asserting deployment-specific facts now
pin those facts to the deployment operations and check only genuinely universal
ones globally. The regenerated spec is +160/-0 -- the deployment contract is
byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K92qFRNecJ5kNkKcmJ8qkM
…endpoint sends Iteration 1 of unstract:remediation against PR #2269. Fifteen of sixteen findings; F6 is handed back (see below). The two High findings were both invisible from inside the change: F1 — the spec published `ErrorResponse` for 401/403, but `whoami` is deliberately not whitelisted, so `CustomAuthMiddleware` answers every rejection itself with a bare `{"message": ...}` and DRF's exception handler is never reached. A generated client branching on `errors[0].code` would raise on the most common failure the endpoint has. Now declares a `PlatformKeyError` shape that matches the wire, with the 500 carrying no body schema because a Django HTML 500 has none. F2 — `@pytest.mark.critical_path("platform-key-whoami")` named an id in no registry, and `tests/rig/cli.py` sets `overall_exit = 1` on an unknown marker. The rig would have failed the build on every run. CI never caught it because `test` skips on draft PRs. Now registered. Also fixed: the view returned 403 where the spec said 401 (DRF coerces `NotAuthenticated` unless the first authenticator offers a WWW-Authenticate header, and SessionAuthentication offers none) — it now returns 401 explicitly, and the branch has a test, which it never had because every other rejection is answered before the view runs. The whitelist regex is anchored, so an organisation named `whoami` no longer has its whole API treated as organisation-less. The organisation-scoped alias `/<org>/whoami/`, which the mount comment wrongly claimed could not exist, is now documented and tested in both directions. The dominant defect class was not any single bug: seven of sixteen findings were confidently-worded comments asserting mechanisms the code does not implement — that DRF resolves no user, that the org FK is non-null by construction, that no rewrite can produce `whoami/`, that a test observes middleware it never invokes. Each is corrected to what the code does, or deleted. Every fix is mutation-checked: reverting it fails a named test. That check also caught one of the new tests passing vacuously — it matched an error string the OpenAPI-validity gate produces, so it went green with the gate it was written for removed. F6 is not fixed here and needs a decision: setting `organization_id = None` makes `whoami` bypass `SubscriptionMiddleware` in the enterprise tree, where every other org-less path has an explicit `SUBSCRIPTION_WHITELISTED_PATHS_LIST` entry. The fix belongs in unstract-cloud and the intent is a product call. Findings: F1 F2 F3 F4 F5 F7(partial) F8 F9 F10 F11 F12 F13 F14 F15 F16 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K92qFRNecJ5kNkKcmJ8qkM
|
@chandrasekharan-zipstack @hari-kuriakose — one finding from this PR's review needs a decision from you, because the fix isn't in this repo.
|
Iteration 2. The adversarial verifier re-found F1: iteration 1 changed the
401/403 `$ref` to `PlatformKeyError` but left the `examples` block beside it
showing the handler's `{type, errors[]}` body, so the artifact contradicted
itself in a single media-type object -- schema requiring `message`, example
showing `errors[0].code`. A client author reads the example, not the `$ref`, so
the defect F1 was filed against survived its own fix.
Cause: `drf_standardized_errors` appends an example of the handler body to
every 4xx/5xx keyed on the status code alone, never consulting the declared
serializer (`openapi.py:343-356`), so overriding `responses` was never going to
be enough. `PlatformKeyAutoSchema` suppresses that injection for this view
only; the deployment operations keep their examples, which are correct there
because those really do return the handler body.
Also fixed, all four raised by the same verifier against iteration 1's own work:
the module docstring claimed the middleware answers *every* rejection, which the
401 iteration 1 added to the view had just made false; the new spec test lacked
the `assert reads` guard that the same commit added twelve lines below it, so it
passed vacuously on an operationId rename; the alias test's docstring claimed
mount-order coverage a mutation disproved (swapping the mount leaves every test
green); and the 403 body was declared but asserted nowhere, which is the exact
"status asserted, body unasserted" gap that let the original spec lie.
`test_no_published_example_contradicts_its_own_schema` now pins the property
structurally for every operation. It found three pre-existing instances in the
merged deployment spec -- `status` 406/500 and `execute` 500 all declare a
non-error body beside a handler-shaped example. Those are recorded in
`_KNOWN_EXAMPLE_DIVERGENCES` rather than fixed here: they predate this branch
(verified against c49968e) and belong to whoever owns that spec. The check
fails on any new instance.
Findings: F1(re-fixed) N2 N3 N4 N5
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K92qFRNecJ5kNkKcmJ8qkM
…claiming it cannot
Iteration 3. The verifier re-found N2 with a counter-example rounds 1 and 2
both missed: `POST /api/v1/unstract/whoami/` with a `read_write` key -- the
model default -- is passed by the middleware, refused by DRF, and comes back
405 in the exception handler's `{type, errors[]}`. Four comments across three
files asserted that shape was unreachable here, and that claim was the stated
justification for both not reusing `ErrorResponse` and suppressing every error
example on the view.
So the endpoint has always returned two error shapes and the spec described
one. 405 is now declared with `ErrorResponse`, and `PlatformKeyAutoSchema`
narrows its suppression to the statuses the middleware actually answers (401,
403) rather than blanket-stripping, so the 405 keeps the handler example that
is correct for it. Verified on the wire: a `read_write` key POSTing gets 405
`{type, errors[]}`; a `read` key gets 403 `{message}` from the middleware
instead. Both are now pinned, against each other, so neither path can look like
the only one.
The absolute clauses are deleted rather than rewritten a third time. Two
rounds of rewriting them produced two more false statements; what remains says
only what is enforced.
Also from the same verifier: `_KNOWN_EXAMPLE_DIVERGENCES` was keyed on
`(operationId, code)`, which exempted those coordinates forever -- a new
divergent example injected at `status` 406 passed. Now keyed on the example
name too, so the three recorded rows stay exempt and a fourth fails; the
comment claiming the check catches new instances is true as written for the
first time. Dropped a dead exemption branch that could never match, and a
comment misattributing which commit added a guard and how far below it sits.
Findings: N2(re-fixed) + 4 new from verifier round 2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K92qFRNecJ5kNkKcmJ8qkM
for more information, see https://pre-commit.ci
…not return Iteration 4, and the last: N2 is escalated rather than attempted a fourth time. Round 3 declared 405 under the `get` operation. A verifier's 42-cell matrix (3 tiers x 7 methods x 2 routes) shows GET returns 200, 401 or 403 and never 405 -- so the declaration was unreachable at the coordinate it was written, and the 405s that do occur (POST/PUT/PATCH at read_write+, DELETE at full_access) have no operation in the spec to attach to. The kept example made it worse: it published `Method "get" not allowed.` under the GET operation, a hardcoded placeholder from drf_standardized_errors, not derived from the route. The declaration is withdrawn. The behaviour is real and is now stated in the operation description in prose, which is where a fact about methods the route does not serve can honestly live. Also corrected, all introduced by round 3 and all found by the same verifier: an I001 failure of the repo's PINNED ruff 0.3.4 hook, which round 3 introduced and the installed ruff 0.15 does not report -- verified clean at 7eb41bc and failing at 32ec142, then confirmed the fix satisfies both versions; a comment asserting everything else "keeps the handler shape", contradicted by the 500 declared eight lines below it; a true clause round 3 deleted while rewriting the sentence around it, restored verbatim; and a test whose name claimed a relationship to the spec that nothing in it tested -- renamed to what it actually asserts, with the gap it does not cover named in its docstring. WHAT IS ESCALATED, and why this loop stops here: N2 has survived three fix attempts. Each attempt was a correct reading of the previous failure and each introduced a new defect: round 2 rewrote a false claim into a stronger false one, round 3 fixed the claim and declared the status in the wrong place. Three consecutive rounds introducing new defects is the convergence tripwire, and the discriminator applies -- the fixes keep needing an exception to a shared rule, which means the rule is wrong, not the wording. The rule that is wrong: this path serves GET, but answers non-GET methods with a status and a body shape that no `get` operation can describe. OpenAPI attaches responses to operations, not paths. Options, none of which a remediation loop should pick unilaterally: declare stub operations for the methods purely to carry a 405; accept prose (what this commit does); or change the view so every method the tier permits is answered in one shape. Findings: NEW-1..NEW-5 from verifier round 3 fixed; N2 ESCALATE (attempts=3) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K92qFRNecJ5kNkKcmJ8qkM
|
…the default points Iteration 1 of unstract:remediation against PR #3. Nineteen findings across fifteen classes; one escalated (below). The headline defect had four faces and one cause. `config.py` stated a relationship in a comment -- "the same host as docstudio: one deployment serves both the platform API and the deployments it manages" -- and implemented it as a constant on the very next line. So `platform.base_url` ignored a profile written before the `platform` block existed, ignored `docstudio --base-url`, and left `--api-key` inert for `deployment ls`, sending an organisation-admin platform key to `us-central.unstract.com` after the operator had explicitly named their own host. A reviewer proved it by pointing `--base-url` at 127.0.0.1:9 and getting a real 401 back: a closed port cannot answer, so the request reached the SaaS default. `platform_base_url()` now falls back to the resolved docstudio host, which fixes all four sites at once. The security scan rates it 3/10 as a vulnerability -- vendor host, TLS, and `requests` strips the auth header across hosts -- so it lands as a correctness defect, not a leak. The other four High findings: - `_store_organisation` re-derived the profile ladder and dropped the `$UNSTRACT_PROFILE` tier, so the key resolved from one profile and the org was written into another; the next command then failed after a `whoami` that reported `saved: true`. It now takes `ResolvedConfig.active_profile`, the same chain every read uses. It also refuses to *create* a profile that is not in the file: `setdefault` was materialising a typo, permanently disarming the "Profile not found" guard so every later command silently resolved production defaults. - A failed config write threw away an identity the network call had already returned, exiting 1 ("check your disk") or 2 ("usage error") with `data: null`. `ExitCode.SAVE_FAILED` exists for exactly this and `poll.py` already uses it; the identity now reaches stdout in `details` either way. - `config init` wrote `api_key = "env:UNSTRACT_PLATFORM_KEY"` into every starter profile, and an `env:` reference to an unset variable is a `config doctor` problem -- so doctor exited 1 for every user without a platform key, which this PR's own comment calls the common case. The key is dropped from the starter blocks. - `core/platform.py` -- the only wire-facing new code -- had no test executing it at all, because every command test replaces the factory. Two mutations (breaking the whoami URL, forcing every listing to organisation "") left the suite green. `tests/test_platform.py` now exercises the real class; both mutations fail it. Also fixed: a 204 or non-dict body raised an AttributeError that matched no arm in `__main__`, so the caller got a traceback and no envelope -- the one thing this CLI promises never to do; `requests` transport errors (a scheme-less base_url, a proxy's HTML on a 200) reached the entry point's full-disk handler and were reported as "Check the path and disk."; `PlatformAPIError` folded up to 2KB of server body into `error.message`, which `emit_error` documents as a one-line summary; `--transport-timeout` was accepted on `deployment ls` and ignored (8.65s elapsed against a 1s flag), and `auth` had no such flag at all; `api_path_prefix` was hard-wired, so whoami and ls were unreachable on precisely the self-hosted installs the onprem-example profile caters to, while `clone` worked; `whoami --save` rewrote a discovered project-local `.unstract.toml`, dropping its comments and narrowing its mode; the write was invisible outside `-o json`; the 401 hint talked about deployments on a command that has none; and the README claimed a deployment key "runs one deployment", contradicted by three other statements including this file's own KEY_SOURCES. Every fix is mutation-checked: reverting it fails a named test, nine of nine. ESCALATED, needs a decision: `GET /api/v1/unstract/whoami/` is served only by Zipstack/unstract#2269, which is unmerged. Against any released Unstract the README's documented first command 404s, and `hint_for(404)` sends the reader after a resource id that does not exist. `config doctor --probe` inherits it and exits non-zero on a good setup. Whether this CLI ships before the backend, and what it should say when it does, is a release call rather than a fix. Findings: A B C D E F G H I J K L M N O(escalated) P Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01K92qFRNecJ5kNkKcmJ8qkM
|
| Filename | Overview |
|---|---|
| backend/platform_api/whoami_views.py | Adds the organization-less identity response using the platform key already resolved by authentication middleware. |
| backend/account_v2/custom_auth_middleware.py | Not changed directly; investigation confirmed it remains the authentication and platform-key organization boundary used by the new endpoint. |
| backend/middleware/organization_middleware.py | Ensures organization-less whitelisted requests still define organization_id before downstream middleware executes. |
| backend/backend/base_urls.py | Mounts the dedicated whoami URL configuration ahead of the tenant URL set under the shared API prefix. |
| backend/backend/settings/base.py | Adds the narrowly anchored organization-middleware exception and platform-key OpenAPI metadata. |
| backend/platform_api/openapi_schema.py | Defines the identity operation, response fields, platform-key security scheme, and middleware-specific error shapes. |
| backend/api_v2/management/commands/generate_docstudio_spec.py | Broadens spec path validation to the two intended public mounts while retaining rejection of other prefixes. |
| specs/docstudio-oss.json | Publishes the generated whoami operation and its response and authentication components. |
Sequence Diagram
sequenceDiagram
participant C as API Client
participant O as OrganizationMiddleware
participant A as CustomAuthMiddleware
participant W as WhoAmIView
participant D as PlatformApiKey / Organization
C->>O: "GET /api/v1/unstract/whoami/<br/>Bearer platform key"
O->>O: "Preserve org-less path<br/>organization_id = None"
O->>A: Continue middleware chain
A->>D: Resolve active key and organization
D-->>A: Key, service account, organization
A->>W: Bind platform_api_key and request.user
W->>D: Read key and organization fields
W-->>C: "organization_id, organization_name,<br/>permission, key_name"
Reviews (1): Last reviewed commit: "UN-4016 [FIX] Withdraw the 405 declarati..." | Re-trigger Greptile
Unstract test resultsPer-group results
Critical paths
|



What
GET /api/v1/unstract/whoami/— an organisation-less endpoint that authenticates with a platform API key and answers{organization_id, organization_name, permission, key_name}, read off the key row itself. It reaches the generated OpenAPI spec, so the published client and the CLI pick it up.Builds on the spec pipeline from #2237 (UN-4009), which merged on 31 Aug — this was written against that branch and has been rebased onto
mainsince, with the spec regenerated and re-verified against the merged result.Why
Before a caller can use any organisation-scoped endpoint they need
org_id, and it has no documented source — it is the first path segment of every web-app URL (useMainAppRoutes.js,<Route path=":orgName">) and nothing says so. A platform key already carries its organisation by construction:PlatformApiKeyinheritsDefaultOrganizationMixin, whose FK is stamped at mint time from the minting admin's active org, andkeyis globally unique, so a bearer token maps to exactly one row and therefore one organisation. Nothing exposed that association over HTTP.Every comparable CLI that predates OAuth (
doctl,twilio,sentry-cli) issues one opaque token that carries its own scope and is validated the moment it is supplied. This is the endpoint that lets ours do the same.How
platform_api/whoami_views.py— a field read.permission_classes = []becauseCustomAuthMiddlewarehas already resolved the token to a key row, bound the service account torequest.userand enforced the tier against the method; a permission class would re-ask a question already answered.authentication_classesis deliberately not set — the project configures noDEFAULT_AUTHENTICATION_CLASSESthat resolves a user, so DRF's default returnsNoneand the middleware'srequest.usersurvives into the view. No extra query:custom_auth_middleware.pyalreadyselect_relatedsorganization.platform_api/whoami_urls.py+ a mount inbase_urls.py— its own module because the spec's urlconf selector can only pick out a mount declared with a dotted module path (the tenant and public mounts pass a list, sourlconf_name.__name__isNonefor them). Mounted ahead of the tenant urlconf so resolution is deterministic rather than relying on fall-through; organisation-scoped paths are rewritten before routing and none of those rewrites can producewhoami/.OrganizationMiddlewarematches^/api/(v[12])/unstract/(?P<org_id>[^/]+)/, so/api/v1/unstract/whoami/parsedwhoamias the organisation and rewrotepath_infoto/api/v1/unstract/→ 404, withrequest.organization_id = "whoami"making the org-match guard atcustom_auth_middleware.py:94403 every valid key. The whitelist escape hatch (ORGANIZATION_MIDDLEWARE_WHITELISTED_PATHS, previously[]) returns early without ever settingrequest.organization_id, which that same line reads by bare attribute access →AttributeError→ 500. So: the path is added to that list, and the whitelist branch now sets the attribute. The second half is a latent-bug fix that protects any future organisation-less path —test_whoami.pyfails with exactly thatAttributeErrorif it is removed.This is not
WHITELISTED_PATHS: that list skips authentication entirely, which for this endpoint would mean answering with someone else's organisation or none at all. A test asserts it stays out.platform_api/openapi_schema.py— mirrorsapi_v2/openapi_schema.py: a spec-only serializer that never builds a response, withpermissionsourced fromApiKeyPermission.choicesso a new tier cannot reach the API without reaching the spec.auth=[{"platformKey": []}]is mandatory, not decorative — an operation that omits it regresses tocookieAuth/basicAuth, because DRF's unset authentication default gets introspected as a decision.API_DEPLOYMENT_PATH_PREFIX);SPEC_URLCONFSgains the new urlconf; and the two tests that looped over every operation asserting deployment-specific facts now pin those to the deployment operations and assert only genuinely universal ones (401/403/500, and that each operation names exactly one declared bearer scheme) globally. Declaring400/404on an operation that carries no body and names no resource would hand clients a dead branch — the same sintest_only_the_execution_endpoint_...already guards against.Can this PR break any existing features. If yes, please list possible items. If no, please explain why.
The two middleware changes are the risk surface, and both are additive:
ORGANIZATION_MIDDLEWARE_WHITELISTED_PATHSgoes from[]to one regex matching only/api/v1/unstract/whoami/. Cloud and on-prem.append()to this list rather than reassigning it, so their existing entries are unaffected.request.organization_id = Nonein the whitelist branch can only turn anAttributeErrorinto the skip the earlyreturnalready intended. No path reached that branch before this PR, since the list was empty.The new URL mount sits ahead of the tenant urlconf but cannot shadow it:
OrganizationMiddlewarerewrites every organisation-scoped path to/api/v1/unstract/<rest>/before routing, and no rewrite yieldswhoami/. Verified by hand against a running server —/api/v1/unstract/acme/api/deployment/still returns 200 with the same key, and a key from another organisation still gets 403 there.Cloud and on-prem inherit both the mount and the setting with no change in
unstract-cloud:cloud_base_urls.pyandonprem_base_urls.pybothfrom .base_urls import urlpatternsand append.The regenerated spec is +160/−0 — the deployment operations are byte-identical, so nothing generated from it changes.
Database Migrations
None. No model changes.
Env Config
None.
ORGANIZATION_MIDDLEWARE_WHITELISTED_PATHSis a settings constant, not an env var, and is not intended to be overridden per installation.Relevant Docs
None in this repo.
unstract-docshas aPlatform API Keyspage that could gain awhoamientry once this lands; not blocking.Related Issues or PRs
unstract auth whoami,docstudio deployment ls); PR pending repo accessDependencies Versions
None added or changed.
Notes on Testing
46 passedin the two suites this touches (platform_api/,api_v2/tests/test_docstudio_spec.py), of which 10 + 3 subtests are new endpoint tests and 6 are new spec anchors. Full backend suite baselined against the unmodified parent commit: identical failure set, +16 passing.platform_api/tests/test_whoami.pygoes through the real URLconf and a real middleware chain, because everything that makes this endpoint work happens before the view. It covers: the organisation resolving from the key and not the URL (two organisations, one URL, two answers); every tier reading its own identity; missing/malformed/unknown/inactive keys; that the route actuallyresolve()s, so a 404 cannot masquerade as a passing 401; and that the path is not inWHITELISTED_PATHS.Also exercised by hand against a running server with two seeded organisations —
whoamianswers each key with its own organisation over the same URL, all four rejection paths return 401, and the organisation-scoped regression checks above pass.Screenshots
n/a — no user-facing surface.
Checklist
I have read and understood the Contribution Guidelines.
🤖 Generated with Claude Code
https://claude.ai/code/session_01K92qFRNecJ5kNkKcmJ8qkM