Skip to content

Enforce access checks on mutating routes + add coverage guard - #2334

Open
ruizhang0519 wants to merge 36 commits into
DataJunction:mainfrom
ruizhang0519:enforcement-coverage
Open

Enforce access checks on mutating routes + add coverage guard#2334
ruizhang0519 wants to merge 36 commits into
DataJunction:mainfrom
ruizhang0519:enforcement-coverage

Conversation

@ruizhang0519

@ruizhang0519 ruizhang0519 commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

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.

Tracking: #2234 (step 0).


Runthrough

Local server, AUTHORIZATION_PROVIDER=rbac, DEFAULT_ACCESS_POLICY=restrictive. alice is a non-admin whose only grant is WRITE on namespace finance.*. Same database, same requests, main on one port and this branch on the other.

1. Materializing a node she has no grant on

main:

POST /nodes/marketing.demo.leads_t/materialization  ->  500

The 500 is the unreachable local query service: the request had already passed authorization and begun scheduling the job. With a query service configured this returns 201, writes the materialization config, and — since upsert updates in place — can overwrite the owner's existing config.

this PR:

POST /nodes/marketing.demo.leads_t/materialization  ->  403
{"message":"Access denied to 1 resource(s): marketing.demo.leads_t"}

2. Creating a namespace outside her scope

main:

POST /namespaces/marketing.sub2/  ->  201
{"message":"The following node namespaces have been successfully created: marketing.sub2"}

this PR:

POST /namespaces/marketing.sub2/  ->  403
{"message":"Access denied to 1 resource(s): marketing.sub2"}

3. Her actual grant still works

this PR:

POST /namespaces/finance.sub/    ->  201
POST /namespaces/marketing.sub/  ->  403

Authorization targets the namespace being created, so a finance.* scope covers finance.sub. Checking the parent instead would deny it, because finance.* does not match finance.


Notes

POST /preaggs/{id}/availability is deliberately excluded: it is a query-service callback rather than a user action, so governing it with user WRITE would break callbacks. It needs a service-identity model.

Why this is a no-op today: the default access policy is permissive, so these checks allow requests unless an explicit restrictive grant or policy denies them. They take effect once a deployment enables restrictive RBAC.

Out of scope, tracked as step-0 follow-ups: authorization on non-HTTP (deployment and background) paths, the availability service-identity model, and denial tests for the routes still listed in the backlog.

@netlify

netlify Bot commented Jul 16, 2026

Copy link
Copy Markdown

Deploy Preview for thriving-cassata-78ae72 ready!

Name Link
🔨 Latest commit debf3e2
🔍 Latest deploy log https://app.netlify.com/projects/thriving-cassata-78ae72/deploys/6a6bdfa72352d40008821caf
😎 Deploy Preview https://deploy-preview-2334--thriving-cassata-78ae72.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@ruizhang0519
ruizhang0519 force-pushed the enforcement-coverage branch from d2d4900 to ffc3e61 Compare July 17, 2026 01:39
@ruizhang0519
ruizhang0519 force-pushed the enforcement-coverage branch from ffc3e61 to 3c8787d Compare July 17, 2026 01:39
Comment thread datajunction-server/tests/test_route_coverage.py
@ruizhang0519 ruizhang0519 changed the title Add route-coverage guard for mutating endpoints [In Progress] Enforcement coverage + grant-authority lockdown Jul 17, 2026
@ruizhang0519 ruizhang0519 changed the title [In Progress] Enforcement coverage + grant-authority lockdown Enforcement coverage + grant-authority lockdown Jul 21, 2026
@ruizhang0519 ruizhang0519 changed the title Enforcement coverage + grant-authority lockdown Enforce access checks on mutating routes + add coverage guard Jul 21, 2026

@router.post("/namespaces/{namespace}/", status_code=HTTPStatus.CREATED)
async def create_node_namespace(
async def create_or_reactivate_namespace(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Can this be moved into an internal/namespaces.py?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

@philipfweiss philipfweiss Jul 23, 2026

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.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@philipfweiss

Copy link
Copy Markdown
Contributor

OK- some small bugs/findings from an agent stress test:

  1. Build the preaggregation measures once.
    The route currently calls build_measures_sql once for authorization and again during registration. The calls use different use_materialized values, so they can resolve different parent nodes. Please build once with dialect=Dialect.SPARK, use_materialized=False and pass that result into register_external_preaggregations.

  2. Make namespace authorization consistent.
    The new endpoint checks WRITE on the parent namespace, while register_table, node creation, and restore check the target namespace. This conflicts with wildcard scopes such as finance.*. Please authorize the target namespace, or check both parent and target if the parent boundary is intentional. include_parents should also authorize every namespace it creates.

Examples:
Suppose a measure can use an available materialized parent. The authorization build uses use_materialized=True and may check permissions for that materialized parent. Registration rebuilds with use_materialized=False and may write using the base parent instead. The request is therefore authorized against one node set and executed against another.

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
POST /register/table/finance/sub/widgets/?source_node_namespace=
can create the namespace and then return 404.

Creating a.b.c.d with include_parents=true authorizes only a.b.c, even though it also creates a and a.b.

@philipfweiss

philipfweiss commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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)

@philipfweiss philipfweiss 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.

LGTM pending comments being addressed

@philipfweiss

Copy link
Copy Markdown
Contributor

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.

@ruizhang0519

Copy link
Copy Markdown
Contributor Author

OK- some small bugs/findings from an agent stress test:

  1. Build the preaggregation measures once.
    The route currently calls build_measures_sql once for authorization and again during registration. The calls use different use_materialized values, so they can resolve different parent nodes. Please build once with dialect=Dialect.SPARK, use_materialized=False and pass that result into register_external_preaggregations.
  2. Make namespace authorization consistent.
    The new endpoint checks WRITE on the parent namespace, while register_table, node creation, and restore check the target namespace. This conflicts with wildcard scopes such as finance.*. Please authorize the target namespace, or check both parent and target if the parent boundary is intentional. include_parents should also authorize every namespace it creates.

Examples: Suppose a measure can use an available materialized parent. The authorization build uses use_materialized=True and may check permissions for that materialized parent. Registration rebuilds with use_materialized=False and may write using the base parent instead. The request is therefore authorized against one node set and executed against another.

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 POST /register/table/finance/sub/widgets/?source_node_namespace= can create the namespace and then return 404.

Creating a.b.c.d with include_parents=true authorizes only a.b.c, even though it also creates a and a.b.

Good call and both updated.

1. The register route now resolves grain groups once, with dialect=Dialect.SPARK, use_materialized=False, and passes the result into register_external_preaggregations (new optional arg, so the deploy path is unchanged). Confirmed the bug first: the auth build was defaulting use_materialized to True while registration used False.

2. Namespace creation now authorizes the namespaces it creates rather than the parent, matching node creation and register_table. One nuance on your alternative: checking both parent and target wouldn't help, since AccessChecker conjoins requests, so the parent request is exactly what finance.* fails to match. Same reasoning for include_parents — authorizing every ancestor would 403 on finance itself, so it authorizes only the namespaces actually being created and skips existing ones. Test added for the finance.* case, which returned 403 before.

@ruizhang0519

Copy link
Copy Markdown
Contributor Author

Also updated PR description

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.

3 participants