Enforce access checks on mutating routes + add coverage guard - #2334
Enforce access checks on mutating routes + add coverage guard#2334ruizhang0519 wants to merge 33 commits into
Conversation
✅ Deploy Preview for thriving-cassata-78ae72 canceled.
|
d2d4900 to
ffc3e61
Compare
ffc3e61 to
3c8787d
Compare
|
|
||
| @router.post("/namespaces/{namespace}/", status_code=HTTPStatus.CREATED) | ||
| async def create_node_namespace( | ||
| async def create_or_reactivate_namespace( |
There was a problem hiding this comment.
Can this be moved into an internal/namespaces.py?
There was a problem hiding this comment.
Moved it to internal/namespaces.py, next to create_namespace. Also dropped the JSONResponse return so HTTP shaping stays in the endpoint — it now returns a NamespaceWriteResult (created / reactivated / already_exists). Status codes and bodies unchanged.
Already-exists returns a status rather than raising, since register_* discard the result and just need the namespace to exist. Added a test to pin that.
| @@ -24,8 +24,10 @@ | |||
| from datajunction_server.internal.access.authentication.http import SecureAPIRouter | |||
There was a problem hiding this comment.
OK, so after some stress testing found an issue. upsert_materialization never checks WRITE on node_name.
create_new_materialization sends transforms and dimensions through build_non_cube_materialization_config, which is not passed an access_checker, and the DRUID_CUBE job through build_cube_materialization, which also does not check. The only branch that reaches .check() is the non-DRUID cube job, and that check is READ on the cube's metrics and dimensions, not WRITE on the node being materialized. So there is no WRITE gate on this endpoint for any node type.
Quick Example:
Let's say Restrictive RBAC governs finance; Alice has no grant on it.
POST /nodes/finance.orders/materializations/{name}/backfill returns 403
POST /nodes/finance.orders/materialization with a valid transform body returns 201: DJ writes the materialization, records history, and schedules the workflow. Because upsert updates in place, Alice can also overwrite the owner's existing config and reschedule it.
Suggested fix:
Add access_checker.add_request_by_node_name(node_name, ResourceAction.WRITE) and await access_checker.check(on_denied=AccessDenialMode.RAISE) at the top of the handler, matching deactivate and backfill.
There was a problem hiding this comment.
Good catch — confirmed and fixed: added the WRITE check at the top of upsert_materialization, matching deactivate and backfill.
I also added a completeness test: every mutating route now needs a denial test or an explicit backlog entry. The denial suite is up to 21 cases (including a deny-DELETE variant), with 42 routes backlogged with reasons.
…9/dj into enforcement-coverage
|
OK- some small bugs/findings from an agent stress test:
Examples: Alice has WRITE on finance., which lets her write nodes under finance. When she creates finance.sub, the endpoint checks WRITE on finance. Since finance. does not match finance, she gets a 403. Giving her exact access to finance fixes namespace creation but no longer covers nodes below it. Alice can still create the namespace through register_table, which checks finance.sub. That matches finance.*. The namespace is committed before catalog validation, so a request such as Creating a.b.c.d with include_parents=true authorizes only a.b.c, even though it also creates a and a.b. |
|
The PR description is a bit confusing/roundabout- I think clearer is something like: This PR adds missing WRITE authorization checks to namespace creation/reactivation, cube mutations, materialization configuration, preaggregation mutations, and dimension-link removal. It also fixes materialization upserts that previously checked READ on related metrics instead of WRITE on the node being changed. It adds two regression guards: one verifies every mutating HTTP route reaches AccessChecker.check() or has a documented exclusion; the other verifies covered routes reject denied writes. These checks remain permissive until restrictive RBAC is enabled. (Then explicit examples) |
|
One other quick thing- for these PRs, I find it not very helpful to have unit test coverage in pr description- that is tablestakes and also part of the CI checks. Instead, I feel more effective is the structure of having an explicit runthrough of a case that fails pre-pr, and an explicit run through of the same case after the pr. That will help give the reviewer a better feel for what the change is doing and why. |
Tracking: #2234 (step 0 of the RBAC enablement sequence).
RBAC is opt-in per endpoint: a mutation is only authorized if its handler reaches
AccessChecker.check(). With the permissive default, an endpoint that never callscheck()fails open silently — nothing turns red today, but it becomes a hole once a namespace is governed. Several mutating endpoints were uncovered (remove_complex_dimension_linkbuilt an access request and never checked it), and nothing guarded against the next one.This PR makes the mutating-route surface covered and keeps it that way, with two guards that answer different questions:
tests/test_route_coverage.pyenumerates every mutating route and asserts each reachescheck()or is excluded with a reason. 67 routes reach a check; 30 are excluded across 7 reason buckets.tests/api/write_enforcement_test.pyasserts every mutating route has a denial test, is excluded, or is in a backlog with a reason. 19 cases execute (16 deny-WRITE, 3 deny-DELETE), covering 26 routes; 41 remain backlogged by reason.The second guard exists because the first cannot tell a correct check from a wrong one. Review found exactly that case:
upsert_materializationreached aREADcheck on a cube's metrics while writing to the node itself, so the coverage guard was green while transforms, dimensions and Druid cubes had noWRITEgate at all. A caller deniedWRITEcould create or overwrite a materialization config and reschedule its job. Fixed, with a denial test.Checks added, each
WRITEon the affected resource: dimension-link removal (node), namespace create (parent boundary), materialization writes including the upsert gap above (node), cube writes (cube node), and preaggregation writes (the node the pre-agg is based on).Intentionally excluded:
POST /preaggs/{id}/availabilityis a query-service callback, not a user action; governing it with userWRITEwould break callbacks, so it awaits a service-identity model.Also from review:
create_or_reactivate_namespacemoved tointernal/namespaces.pynext to thecreate_namespaceit delegates to, and returns aNamespaceWriteResultinstead of aJSONResponseso HTTP shaping stays in the endpoint. Status codes and bodies are unchanged. The already-exists case returns a status rather than raising, becauseregister_table/register_viewdiscard the result and only need the namespace to exist.Why this won't take effect now: DJ's default access policy is permissive, so these checks allow requests unless an explicit restrictive grant or policy denies them.
When this will start taking effect: once a deployment enables restrictive RBAC and governs a namespace. Until then this is a no-op.
Out of scope (step-0 follow-ups): action/resource correctness, authorization on non-HTTP (deployment and background) paths, the
availabilityservice-identity model, and the 41 backlogged denial tests.Verification:
1. Both guards, plus every executing denial case.
2. Denial tests that need real fixtures, next to those fixtures.
3. The
upsert_materializationgap, before and after.4. Full server suite with the 100% coverage gate.