feat(b2b): add a service-scoped org-manager check endpoint - #3807
feat(b2b): add a service-scoped org-manager check endpoint#3807blarghmatey wants to merge 1 commit into
Conversation
ol-analytics-api gates its B2B analytics endpoints on org-manager status, and has no way to learn it. is_manager is curated only here, in the Django admin, and never reaches the Keycloak token, so the flag is invisible downstream. It cannot reuse ManagerOrganizationViewSet: that queryset filters on self.request.user, so a service-authenticated call would always answer "manages nothing". It also cannot forward the end user's identity -- the APISIX openid-connect plugin strips client-supplied X-Userinfo and X-Access-Token before they reach an upstream, deliberately, so a forwarded identity never survives the gateway. Today that combination makes every analytics request 502. Hence a service credential plus an explicit subject parameter. Kept in its own module rather than folded into the manager viewset so the user-facing dashboard path is untouched, and so removing it later is a file deletion rather than unpicking a branch out of a live authorization path. Fails closed throughout: an unknown user, unknown org, or missing membership row all answer is_manager=false rather than 404, which also avoids letting a caller enumerate which org UUIDs and user IDs exist. Temporary. Delete along with the b2b:manager-check scope once org-manager status is visible in Keycloak (mitodl/hq#10594). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AHYhdMkjAVj1suk9YwQzC
OpenAPI ChangesShow/hide changesUnexpected changes? Ensure your branch is up-to-date with |
Every B2B analytics request has been 502ing in QA. The org-manager check forwarded the caller's X-Userinfo to MITx Online's manager endpoint, but the APISIX openid-connect plugin clears client-supplied X-Userinfo and X-Access-Token before they reach an upstream -- a deliberate anti-spoofing measure. MITx Online therefore saw an anonymous request and answered 403, which auth.py mapped to 502. Switch to this service's own OAuth2 client-credentials token against the new service endpoint (mitodl/mitxonline#3807), naming the subject user explicitly. MITx Online's existing manager endpoint could not be reused under a service credential: its queryset filters on the authenticated user, so it would answer "manages nothing" for everybody. A side effect worth noting: "not a manager" is now a 200 with is_manager=false, so it surfaces as a 403 to the frontend. Previously it could only ever have arrived as an upstream error, and every outcome -- authorized, unauthorized, misconfigured -- collapsed into the same 502. Tokens are cached until shortly before expiry and minted under a lock, so concurrent cache misses don't stampede the token endpoint. A 401 refreshes once and retries, so a revoked or early-expired token self-heals rather than failing the user's request. Temporary, along with the endpoint it calls: once org-manager status is visible in Keycloak (mitodl/hq#10594) the round-trip disappears entirely. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016AHYhdMkjAVj1suk9YwQzC
There was a problem hiding this comment.
🟢 Ready to approve
The endpoint implementation, scope wiring, and OpenAPI updates align with the stated design and are backed by focused automated tests covering key auth and edge cases.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds a new service-to-service B2B API endpoint that allows an authorized service (via OAuth2 + scope) to ask whether a specified user manages a specified organization, without relying on caller identity. This fits into the B2B authorization surface by providing an explicit “subject + organization” check for downstream services (e.g., analytics) while keeping the existing manager UI endpoints unchanged.
Changes:
- Add
GET /api/v0/b2b/service/organization-manager-check/secured by a newb2b:manager-checkOAuth2 scope. - Implement the endpoint in a dedicated
b2b/views/v0/service.pymodule with fail-closed behavior and request validation forsso_organization_id. - Add serializer + focused pytest coverage, and regenerate OpenAPI specs (v0/v1/v2) to include the new path and response schema.
File summaries
| File | Description |
|---|---|
| openapi/specs/v2.yaml | Documents the new v0 service endpoint and OrganizationManagerCheck response schema in the v2 spec output. |
| openapi/specs/v1.yaml | Same OpenAPI regeneration update including the new v0 endpoint and response schema. |
| openapi/specs/v0.yaml | Adds the new service endpoint path and OrganizationManagerCheck schema to the v0 OpenAPI spec. |
| main/settings.py | Introduces the b2b:manager-check OAuth2 scope in OAUTH2_PROVIDER["SCOPES"]. |
| b2b/views/v0/urls.py | Routes /api/v0/b2b/service/organization-manager-check/ to the new APIView. |
| b2b/views/v0/service.py | Implements the service-scoped org-manager check endpoint using OAuth2Authentication + TokenHasScope. |
| b2b/views/v0/service_test.py | Adds targeted tests validating auth, scope enforcement, parameter validation, and correct true/false semantics. |
| b2b/serializers/v0/service.py | Adds a simple serializer defining the response shape (is_manager). |
Review details
- Files reviewed: 8/8 changed files
- Comments generated: 0
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
What are the relevant tickets?
Related: https://github.com/mitodl/hq/issues/10594 (this endpoint is deleted once that lands)
Description (What does it do?)
Adds a small, service-scoped endpoint that answers one question: does user X manage organization Y?
OAuth2Authentication+TokenHasScope, requiring a newb2b:manager-checkscope that is only ever granted to a service Application. Backed by the existingis_organization_manager()inb2b/models.py.Why this is needed
ol-analytics-apiserves the B2B analytics dashboard and gates every endpoint on org-manager status. It currently gets that status by calling/api/v0/b2b/manager/organizations/and forwarding the end user'sX-Userinfoheader. That has never worked — in QA every request 502s, because MITx Online sees the call as anonymous and returns 403.Two independent reasons a lighter fix isn't available:
It can't reuse
ManagerOrganizationViewSet.get_querysetfilters onself.request.user, so a service-authenticated call answers "manages nothing" and every user is denied.It can't forward the user's identity. Our APISIX build's
openid-connectplugin strips client-supplied identity headers before they reach an upstream, by design:Bearer tokens aren't an alternative either: the plugin's bearer path is gated on
bearer_only or introspection_endpoint or public_key or use_jwks, and the mitxonline route sets none of them, so a bearer token is never inspected at all. (Verified by probing the live RC endpoint — a bogus bearer token returns the identical 403 to sending no header.)So the only remaining option is a service credential plus an explicit subject parameter.
Design notes
b2b/views/v0/service.py), not folded intomanager.py. The user-facing dashboard path is completely untouched — no risk of regressing the live manager UI — and the eventual removal is a file deletion rather than unpicking a conditional out of a live authorization path.is_manager: falserather than 404. Besides being the safe default, a 404 would let a caller enumerate which org UUIDs and user IDs exist here.sso_organization_idis a 400, not a 500 from the ORM rejecting a non-UUID on aUUIDFieldlookup.How can this be tested?
13 tests covering: manager → true; member-but-not-manager → false (the case that motivates the whole endpoint, since the Keycloak token carries membership but not the manager flag); manager of a different org → false; unknown user/org/both → false; unauthenticated → rejected; valid token without the scope → 403; expired token → rejected; missing params → 400; malformed UUID → 400.
Verified locally against Postgres 15.16:
pytest b2b/views/v0/service_test.py→ 13 passedpytest b2b/→ 510 passed, 1 skipped (the skip is pre-existing), so nothing existing regressedruff checkandruff format --checkclean; pre-commit hooks pass./manage.py generate_openapi_spec --fail-on-warnregenerated; the spec diff is includedAdditional Context
This is one of three PRs. It can merge independently and is inert until a service Application is granted the scope:
Pre-merge step: an OAuth2
Application(confidential,client-credentials) needs creating per environment with theb2b:manager-checkscope, and its client id/secret stored in Vault for ol-analytics-api. No migration or fixture is included — say the word if you'd prefer one over doing it through the admin.The scope appears in all three of
openapi/specs/v{0,1,2}.yamlbecause the generator emits every path into each version's spec;v1.yamlalready contained 20 other/api/v0/b2b/paths before this change, so that's the existing convention rather than something introduced here.Checklist:
b2b:manager-checkscope grant in each environment, and store its credentials in Vault, before the ol-analytics-api side deploys