feat(b2b_dashboard): add contract-scoped endpoints mirroring mitxonline's dashboard - #34
Open
blarghmatey wants to merge 3 commits into
Open
feat(b2b_dashboard): add contract-scoped endpoints mirroring mitxonline's dashboard#34blarghmatey wants to merge 3 commits into
blarghmatey wants to merge 3 commits into
Conversation
…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
There was a problem hiding this comment.
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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_selectprojects each model's own field list, socontract_idmust 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}.routers/contracts.py: the same five panels under/organizations/{organization_id}/contracts/{contract_id}/….build_select/build_counttook onefilter_columnand now take a tuple, so contract routes bind org and contract.contract_id(mitxonline'sContractPage.page_ptr_id) added to the four contract-grained row models; two new models,ContractMonthlyEngagementTrendandContractContentEngagementDepth, back the contract-grained MVs.require_contract_in_orggate, and abuild_existence_checkhelper for it.Authorization does not change — that is a finding, not a shortcut. mitxonline's
IsOrganizationManagerauthorizes at the org level and merely validates that the contract belongs to that org; there is no contract-manager role. Sorequire_org_manageris 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:
mv_b2b_contract_utilizationspecifically because dbt builds it fromdim_contractwith aLEFT JOINto 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 newbuild_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.cohort_policythe 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 intests/test_endpoints.py:test_contract_endpoint_filters_on_both_org_and_contract— assertssso_organization_id = %s AND contract_id = %sin 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 hitsmv_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 inheritedcohort_policystill floors event counts through their cohorts at contract grain, and contract identity is never suppressed.test_column_contract.pyderives 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--8818e9is the tracked follow-up and should be re-prioritised accordingly.Deploy order: ol-data-platform#2559 merges → the next
b2b_analytics_starrocks_jobrun rebuilds the MVs (automatic since #2554, verified in production 2026-08-14) → this deploys. Landing this first would make every contract querySELECTa column that does not exist.🤖 Generated with Claude Code
https://claude.ai/code/session_01QC8Vb9vZiGMPeHd97Fy4Gj