Skip to content

feat(b2b_dashboard): add contract-scoped endpoints mirroring mitxonline's dashboard - #34

Open
blarghmatey wants to merge 3 commits into
mainfrom
feat/contract-scoped-endpoints
Open

feat(b2b_dashboard): add contract-scoped endpoints mirroring mitxonline's dashboard#34
blarghmatey wants to merge 3 commits into
mainfrom
feat/contract-scoped-endpoints

Conversation

@blarghmatey

Copy link
Copy Markdown
Member

What are the relevant tickets?

N/A — no GitHub issue. Tracked in the work graph as tk-scope-the-analytics-api-to-the-contract-mirrorin-cbcb6e. Part of the B2B self-serve analytics effort (mitodl/hq discussion #11845).

Blocked on mitodl/ol-data-platform#2559 — opened as a draft for that reason. build_select projects each model's own field list, so contract_id must exist in the MVs before this can deploy.

Description (What does it do?)

Every layer of this service was scoped to the organization — the route, the auth gate, and the single filter column — while the MIT Learn dashboard it feeds mirrors mitxonline's manager dashboard, which is nested under manager/organizations/{org}/contracts/{contract}.

  • Adds routers/contracts.py: the same five panels under /organizations/{organization_id}/contracts/{contract_id}/….
  • build_select / build_count took one filter_column and now take a tuple, so contract routes bind org and contract.
  • contract_id (mitxonline's ContractPage.page_ptr_id) added to the four contract-grained row models; two new models, ContractMonthlyEngagementTrend and ContractContentEngagementDepth, back the contract-grained MVs.
  • New require_contract_in_org gate, and a build_existence_check helper for it.

Authorization does not change — that is a finding, not a shortcut. mitxonline's IsOrganizationManager authorizes at the org level and merely validates that the contract belongs to that org; there is no contract-manager role. So require_org_manager is reused verbatim. Keeping the route nested under /organizations/ means the contract-admin role that doesn't exist yet becomes a dependency swap at the same path rather than a re-route.

Three design points worth a reviewer's attention:

  • The org predicate stays alongside the contract one. Contract ids are globally unique but not secret; dropping the org filter would let a manager of org A read org B's contract by naming its id. There's a test pinning both predicates and their bound order.
  • A contract outside the caller's org is a 403, not an empty page. Otherwise "not yours" and "yours, but no data yet" are the same response, and mitxonline refuses the request. The gate probes mv_b2b_contract_utilization specifically because dbt builds it from dim_contract with a LEFT JOIN to enrollments — so a real contract nobody has enrolled in still has a row and isn't mistaken for someone else's. Its query comes from a new build_existence_check, kept in the query module so identifier splicing stays in one reviewable place; it projects no column and stays deliberately outside the anonymization chokepoint.
  • The contract models subclass their org counterparts rather than redeclaring them, so the column sets and the cohort_policy the floor reads cannot drift apart. The dbt models are siblings in the same way.

How can this be tested?

uv sync --frozen && uv run pytest — 175 passed (170 before). Five new cases in tests/test_endpoints.py:

  • test_contract_endpoint_filters_on_both_org_and_contract — asserts sso_organization_id = %s AND contract_id = %s in both the page query and the count query, with both values bound in SQL order.
  • test_contract_endpoint_reads_the_contract_grained_mv — content-engagement hits mv_b2b_contract_content_engagement_depth, not the org-grained view that has no contract column.
  • test_contract_not_in_org_is_403_not_an_empty_result.
  • test_contract_route_still_requires_org_management — a non-manager is refused and the contract existence probe never runs, so the new gate can't become a way around the old one.
  • test_contract_endpoint_suppresses_below_the_floor — the inherited cohort_policy still floors event counts through their cohorts at contract grain, and contract identity is never suppressed.

test_column_contract.py derives from the models, so the projection re-checks itself against the new field lists. prek run --all-files — ruff, ruff-format, mypy, secret detection all pass.

Not exercised against a live StarRocks or a deployed environment. The dbt PR verified the underlying views directly against production.

Additional Context

Complement disclosure is now reachable in production. The org-level endpoints stay, so the same learners are published at two grains, and a suppressed contract is recoverable as org_total − (other contracts). While checking the dbt side I found 4 of 58 orgs already hold more than one contract, so this is live on merge rather than a future concern. The per-row floor does not defend against it; tk-k-anonymity-suppress-complement-disclosure-near--8818e9 is the tracked follow-up and should be re-prioritised accordingly.

Deploy order: ol-data-platform#2559 merges → the next b2b_analytics_starrocks_job run rebuilds the MVs (automatic since #2554, verified in production 2026-08-14) → this deploys. Landing this first would make every contract query SELECT a column that does not exist.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj

…ne's dashboard

Every layer of this service was scoped to the organization -- the route, the
auth gate, and the single filter column -- while the MIT Learn dashboard it
feeds has to mirror mitxonline's manager dashboard, which is nested under
manager/organizations/{org}/contracts/{contract}.

Authorization does NOT change, and that is a finding rather than a shortcut.
mitxonline's IsOrganizationManager authorizes at the ORG level and merely
validates that the contract belongs to that org; there is no contract-manager
role. So require_org_manager is reused verbatim and a require_contract_in_org
gate is added alongside it. Keeping the route nested under /organizations/
means a future contract-admin role is a dependency swap at the same path.

build_select/build_count took one filter_column and now take a tuple, so the
contract routes bind org AND contract. The org predicate is not redundant
next to the contract one: dropping it would let a manager of one org read
another's contract by naming its id.

A contract outside the caller's org returns 403, not an empty page --
otherwise "not yours" and "yours, but no data yet" are the same response, and
mitxonline refuses the request. The gate probes mv_b2b_contract_utilization,
which dbt builds from dim_contract with a LEFT JOIN, so a real contract with
no enrollments still exists there and is not mistaken for someone else's.
Its query comes from a new build_existence_check, which lives in the query
module so identifier splicing stays in one reviewable place; it projects no
column and so stays deliberately outside the anonymization chokepoint.

The two contract-grained models subclass their org counterparts rather than
redeclaring them, so the column sets and the cohort_policy the floor reads
cannot drift apart -- mirroring the dbt models, which are siblings the same
way.

Depends on ol-data-platform#2559: build_select projects the model's own field
list, so contract_id must exist in the MVs before this deploys.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds contract-scoped B2B dashboard endpoints while preserving organization-level authorization and anonymization.

Changes:

  • Adds five contract-scoped analytics routes with organization/contract filtering.
  • Adds contract identity models and ownership validation.
  • Generalizes query builders for multiple filters and expands endpoint tests.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/ol_analytics_api/core/db/query.py Supports multi-column filters and existence checks.
src/ol_analytics_api/tenants/b2b_dashboard/app.py Registers contract routes.
src/ol_analytics_api/tenants/b2b_dashboard/auth.py Validates contract membership in an organization.
src/ol_analytics_api/tenants/b2b_dashboard/models.py Adds contract IDs and contract-grained models.
src/ol_analytics_api/tenants/b2b_dashboard/routers/contracts.py Implements contract-scoped endpoints.
src/ol_analytics_api/tenants/b2b_dashboard/routers/organizations.py Adopts multi-column query API.
tests/test_column_contract.py Updates query construction coverage.
tests/test_endpoints.py Tests contract routing, filtering, authorization, and suppression.
tests/test_query_chokepoint.py Updates count-query tests.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/ol_analytics_api/tenants/b2b_dashboard/app.py
Comment thread tests/test_column_contract.py Outdated
blarghmatey and others added 2 commits August 14, 2026 19:13
The suite claims to cover "every real endpoint" but only parameterized over
organizations.ENDPOINTS and the admin constants, so the five contract-scoped
endpoints added in this PR had no case at all -- model, order_by or
cohort-policy drift on them would have passed the suite that exists to catch
exactly that.

Built from contracts.ENDPOINTS and contracts._FILTER_COLUMNS rather than
restated, so a row added to that table is covered here without touching this
file, matching how the org cases already work.

Caught by Copilot in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj
Follow-up to 4aba12b, which was committed before ruff-format reflowed the
new list concatenation. No behavior change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj
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