Skip to content

feat(b2b): add a service-scoped org-manager check endpoint - #3807

Open
blarghmatey wants to merge 1 commit into
mainfrom
b2b-manager-check-service-endpoint
Open

feat(b2b): add a service-scoped org-manager check endpoint#3807
blarghmatey wants to merge 1 commit into
mainfrom
b2b-manager-check-service-endpoint

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

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?

GET /api/v0/b2b/service/organization-manager-check/
    ?sso_organization_id=<keycloak org uuid>&user_global_id=<keycloak sub>
→ 200 {"is_manager": true}

OAuth2Authentication + TokenHasScope, requiring a new b2b:manager-check scope that is only ever granted to a service Application. Backed by the existing is_organization_manager() in b2b/models.py.

Why this is needed

ol-analytics-api serves 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's X-Userinfo header. 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:

  1. It can't reuse ManagerOrganizationViewSet. get_queryset filters on self.request.user, so a service-authenticated call answers "manages nothing" and every user is denied.

  2. It can't forward the user's identity. Our APISIX build's openid-connect plugin strips client-supplied identity headers before they reach an upstream, by design:

    -- Snapshot the client-supplied X-Access-Token (it doubles as a bearer
    -- input via get_bearer_access_token) and clear the four headers this
    -- plugin advertises as outputs so client-supplied values cannot bleed
    -- through to the upstream.
    ctx.openid_connect_client_x_access_token = core.request.header(ctx, "X-Access-Token")
    core.request.set_header(ctx, "X-Access-Token", nil)
    core.request.set_header(ctx, "X-Userinfo", nil)

    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

  • Its own module (b2b/views/v0/service.py), not folded into manager.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.
  • Fails closed. Unknown user, unknown org, or no membership row all return is_manager: false rather than 404. Besides being the safe default, a 404 would let a caller enumerate which org UUIDs and user IDs exist here.
  • A malformed sso_organization_id is a 400, not a 500 from the ORM rejecting a non-UUID on a UUIDField lookup.
  • Marked temporary in three places — module docstring, URL comment, and the scope entry in settings — each pointing at mitodl/hq#10594.

How can this be tested?

pytest b2b/views/v0/service_test.py

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 passed
  • pytest b2b/510 passed, 1 skipped (the skip is pre-existing), so nothing existing regressed
  • ruff check and ruff format --check clean; pre-commit hooks pass
  • ./manage.py generate_openapi_spec --fail-on-warn regenerated; the spec diff is included

Additional 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 the b2b:manager-check scope, 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}.yaml because the generator emits every path into each version's spec; v1.yaml already contained 20 other /api/v0/b2b/ paths before this change, so that's the existing convention rather than something introduced here.

Checklist:

  • Create the OAuth2 Application + b2b:manager-check scope grant in each environment, and store its credentials in Vault, before the ol-analytics-api side deploys

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
@github-actions

Copy link
Copy Markdown

OpenAPI Changes

Show/hide changes
## Changes for v0.yaml:
1 changes: 0 error, 0 warning, 1 info
info	[endpoint-added] at head/openapi/specs/v0.yaml
	in API GET /api/v0/b2b/service/organization-manager-check/
		endpoint added



## Changes for v1.yaml:
1 changes: 0 error, 0 warning, 1 info
info	[endpoint-added] at head/openapi/specs/v1.yaml
	in API GET /api/v0/b2b/service/organization-manager-check/
		endpoint added



## Changes for v2.yaml:
1 changes: 0 error, 0 warning, 1 info
info	[endpoint-added] at head/openapi/specs/v2.yaml
	in API GET /api/v0/b2b/service/organization-manager-check/
		endpoint added



Unexpected changes? Ensure your branch is up-to-date with main (consider rebasing).

blarghmatey added a commit to mitodl/ol-analytics-api that referenced this pull request Jul 31, 2026
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
@blarghmatey
blarghmatey requested a review from Copilot July 31, 2026 18:00

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 new b2b:manager-check OAuth2 scope.
  • Implement the endpoint in a dedicated b2b/views/v0/service.py module with fail-closed behavior and request validation for sso_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.

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.

2 participants