Files
windmill/backend/tests/postgres_trigger_scope.rs
T
Ruben Fiszel 39058c0a01 fix(postgres-triggers): enforce resource-path scopes on ancillary routes (#10222)
* fix(postgres-triggers): enforce resource-path scopes on slot/publication routes

The Postgres trigger ancillary routes (slot management, publication
management, version/logical-level checks, template script creation) relied
only on the route-level middleware, which validates the scope domain+action
but delegates resource-path enforcement to each handler. These handlers made
no check_scopes call, so a token scoped to one postgres resource path (e.g.
postgres_triggers:write:u/alice/*) could drive these endpoints against any
postgres resource in the workspace, including the destructive drop_slot_name
(pg_terminate_backend + pg_drop_replication_slot).

Add a check_scopes call at the top of each affected handler, before any
connection is opened, mirroring the generic trigger CRUD handlers. Read
endpoints require postgres_triggers:read:{path}; write endpoints require
postgres_triggers:write:{path}.

Fixes WIN-2213

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: condense postgres_trigger_scope test module comment

Address Codex nit: state the durable constraint (mismatched scope must fail
before DB access) instead of narrating pre-fix behavior and change history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 09:21:07 +00:00

70 lines
2.3 KiB
Rust

//! Postgres-trigger ancillary handlers (slot / publication / version management)
//! must reject a path-mismatched scoped token before opening any connection —
//! the route-level middleware only checks the scope domain, so per-path
//! enforcement lives in the handlers. Rejecting pre-connection is why these tests
//! need no real Postgres resource.
use axum::{extract::Path, Extension, Json};
use sqlx::{Pool, Postgres};
use windmill_api_auth::ApiAuthed;
use windmill_common::{db::UserDB, error::Error};
use windmill_trigger_postgres::{handler, Slot};
fn scoped_authed(scopes: Vec<&str>) -> ApiAuthed {
ApiAuthed {
email: "alice@windmill.dev".to_string(),
username: "alice".to_string(),
is_admin: false,
is_operator: false,
groups: vec![],
folders: vec![],
scopes: Some(scopes.into_iter().map(str::to_string).collect()),
username_override: None,
token_prefix: None,
read_only: false,
}
}
// A token scoped to `u/alice/db` must not reach a read handler for `u/bob/db`.
#[sqlx::test]
async fn read_handler_rejects_path_mismatched_scope(db: Pool<Postgres>) -> anyhow::Result<()> {
let authed = scoped_authed(vec!["postgres_triggers:read:u/alice/db"]);
let user_db = UserDB::new(db.clone());
let res = handler::get_postgres_version(
authed,
Extension(db),
Extension(user_db),
Path(("test-workspace".to_string(), "u/bob/db".to_string())),
)
.await;
assert!(
matches!(res, Err(Error::PermissionDenied(_))),
"expected PermissionDenied, got {res:?}"
);
Ok(())
}
// The destructive slot-drop handler must reject a write token scoped to another path.
#[sqlx::test]
async fn drop_slot_rejects_path_mismatched_scope(db: Pool<Postgres>) -> anyhow::Result<()> {
let authed = scoped_authed(vec!["postgres_triggers:write:u/alice/db"]);
let user_db = UserDB::new(db.clone());
let res = handler::drop_slot_name(
authed,
Extension(user_db),
Extension(db),
Path(("test-workspace".to_string(), "u/bob/db".to_string())),
Json(Slot { name: "some_slot".to_string() }),
)
.await;
assert!(
matches!(res, Err(Error::PermissionDenied(_))),
"expected PermissionDenied, got {res:?}"
);
Ok(())
}