diff --git a/.sqlx/query-33678a5f6ac72a9eadd485a6000cb7f935a3d45e3dd5140ef4ace00cdcd738bf.json b/.sqlx/query-33678a5f6ac72a9eadd485a6000cb7f935a3d45e3dd5140ef4ace00cdcd738bf.json new file mode 100644 index 00000000..cc96e4a5 --- /dev/null +++ b/.sqlx/query-33678a5f6ac72a9eadd485a6000cb7f935a3d45e3dd5140ef4ace00cdcd738bf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM organizations WHERE id = $1 FOR SHARE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "33678a5f6ac72a9eadd485a6000cb7f935a3d45e3dd5140ef4ace00cdcd738bf" +} diff --git a/.sqlx/query-350162ab1559c6e55ddef35c2637c26e2b44d3213829790dada900f71e01c071.json b/.sqlx/query-350162ab1559c6e55ddef35c2637c26e2b44d3213829790dada900f71e01c071.json new file mode 100644 index 00000000..c6f638f5 --- /dev/null +++ b/.sqlx/query-350162ab1559c6e55ddef35c2637c26e2b44d3213829790dada900f71e01c071.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO invoices (id,org_id,client_id,number,issued_on,due_on,currency,total_cents) VALUES ($1,$2,$3,'PRIVATE-HTTP','2026-09-07','2026-10-07','EUR',1200)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "350162ab1559c6e55ddef35c2637c26e2b44d3213829790dada900f71e01c071" +} diff --git a/.sqlx/query-3bb85b07f2a35129d9f633166ce87f9c8237680ad212f6116a49a9aa1c1bb5f4.json b/.sqlx/query-3bb85b07f2a35129d9f633166ce87f9c8237680ad212f6116a49a9aa1c1bb5f4.json new file mode 100644 index 00000000..7db1e950 --- /dev/null +++ b/.sqlx/query-3bb85b07f2a35129d9f633166ce87f9c8237680ad212f6116a49a9aa1c1bb5f4.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO invoice_line_items (id,invoice_id,time_entry_id,description,minutes,rate_cents,amount_cents) VALUES ($1,$2,$3,'Private line',60,1200,1200)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "3bb85b07f2a35129d9f633166ce87f9c8237680ad212f6116a49a9aa1c1bb5f4" +} diff --git a/.sqlx/query-4dc19b6c9582df38a73ca7a53a0d7cea812d8e56619911383c274e6ed0332e91.json b/.sqlx/query-4dc19b6c9582df38a73ca7a53a0d7cea812d8e56619911383c274e6ed0332e91.json new file mode 100644 index 00000000..f3608c9b --- /dev/null +++ b/.sqlx/query-4dc19b6c9582df38a73ca7a53a0d7cea812d8e56619911383c274e6ed0332e91.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT set_config('default_transaction_isolation', $1, false) AS isolation, set_config('default_transaction_read_only', 'on', false) AS read_only, set_config('lock_timeout', '250ms', false) AS timeout", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "isolation", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "read_only", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "timeout", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "4dc19b6c9582df38a73ca7a53a0d7cea812d8e56619911383c274e6ed0332e91" +} diff --git a/.sqlx/query-52e411d7d09bfd8512d4930c3e08daff5048e156b099c753bd37931f5a5dc4e4.json b/.sqlx/query-52e411d7d09bfd8512d4930c3e08daff5048e156b099c753bd37931f5a5dc4e4.json new file mode 100644 index 00000000..8428d47a --- /dev/null +++ b/.sqlx/query-52e411d7d09bfd8512d4930c3e08daff5048e156b099c753bd37931f5a5dc4e4.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "LOCK TABLE project_tasks IN ACCESS EXCLUSIVE MODE", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "52e411d7d09bfd8512d4930c3e08daff5048e156b099c753bd37931f5a5dc4e4" +} diff --git a/.sqlx/query-67b64d9f78735e108d31ab3f134fa471e893d039bc479975e61b9835581fb771.json b/.sqlx/query-67b64d9f78735e108d31ab3f134fa471e893d039bc479975e61b9835581fb771.json new file mode 100644 index 00000000..dedb4d17 --- /dev/null +++ b/.sqlx/query-67b64d9f78735e108d31ab3f134fa471e893d039bc479975e61b9835581fb771.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pid", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true + ] + }, + "hash": "67b64d9f78735e108d31ab3f134fa471e893d039bc479975e61b9835581fb771" +} diff --git a/.sqlx/query-7236e08ab1e2587cedb3314c5665f165b1f3548f94944a519474a52d2cae2f23.json b/.sqlx/query-7236e08ab1e2587cedb3314c5665f165b1f3548f94944a519474a52d2cae2f23.json new file mode 100644 index 00000000..ee024269 --- /dev/null +++ b/.sqlx/query-7236e08ab1e2587cedb3314c5665f165b1f3548f94944a519474a52d2cae2f23.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO invoices (id,org_id,client_id,number,issued_on,due_on,currency,total_cents) VALUES ($1,$2,$3,'PRIVATE-1','2026-09-07','2026-10-07','EUR',1200)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "7236e08ab1e2587cedb3314c5665f165b1f3548f94944a519474a52d2cae2f23" +} diff --git a/.sqlx/query-7d778a4c3562689f2735cee0c3252f3098418973b9c343af297296b4cd6d2c19.json b/.sqlx/query-7d778a4c3562689f2735cee0c3252f3098418973b9c343af297296b4cd6d2c19.json new file mode 100644 index 00000000..d699d662 --- /dev/null +++ b/.sqlx/query-7d778a4c3562689f2735cee0c3252f3098418973b9c343af297296b4cd6d2c19.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7d778a4c3562689f2735cee0c3252f3098418973b9c343af297296b4cd6d2c19" +} diff --git a/.sqlx/query-832f8eade616662d9f8a2eade800d9ee815947b141ae68b1f6005d2f2497c63e.json b/.sqlx/query-832f8eade616662d9f8a2eade800d9ee815947b141ae68b1f6005d2f2497c63e.json new file mode 100644 index 00000000..2482227c --- /dev/null +++ b/.sqlx/query-832f8eade616662d9f8a2eade800d9ee815947b141ae68b1f6005d2f2497c63e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM organizations WHERE id = $1 FOR NO KEY UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "832f8eade616662d9f8a2eade800d9ee815947b141ae68b1f6005d2f2497c63e" +} diff --git a/.sqlx/query-8767d4345319e85cfe97c762e5623e6505591070f7bcaeedc8a9fe552a41c6dd.json b/.sqlx/query-8767d4345319e85cfe97c762e5623e6505591070f7bcaeedc8a9fe552a41c6dd.json new file mode 100644 index 00000000..460ac576 --- /dev/null +++ b/.sqlx/query-8767d4345319e85cfe97c762e5623e6505591070f7bcaeedc8a9fe552a41c6dd.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users SET active=true,org_role='manager' WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "8767d4345319e85cfe97c762e5623e6505591070f7bcaeedc8a9fe552a41c6dd" +} diff --git a/.sqlx/query-a3c6912b935897434f63782ea7e68c1b53adea6093f81a774f4d8d44b331ae48.json b/.sqlx/query-a3c6912b935897434f63782ea7e68c1b53adea6093f81a774f4d8d44b331ae48.json new file mode 100644 index 00000000..37d8efda --- /dev/null +++ b/.sqlx/query-a3c6912b935897434f63782ea7e68c1b53adea6093f81a774f4d8d44b331ae48.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT current_setting('transaction_isolation') AS \"isolation!\", current_setting('transaction_read_only') AS \"read_only!\", current_setting('lock_timeout') AS \"timeout!\"", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "isolation!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "read_only!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "timeout!", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null, + null, + null + ] + }, + "hash": "a3c6912b935897434f63782ea7e68c1b53adea6093f81a774f4d8d44b331ae48" +} diff --git a/.sqlx/query-b23ebe81aaf5872fa5c5ef9bc4fe78d82a985595b149e330ab71e5bef972f301.json b/.sqlx/query-b23ebe81aaf5872fa5c5ef9bc4fe78d82a985595b149e330ab71e5bef972f301.json new file mode 100644 index 00000000..4588ba58 --- /dev/null +++ b/.sqlx/query-b23ebe81aaf5872fa5c5ef9bc4fe78d82a985595b149e330ab71e5bef972f301.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM organizations WHERE id=$1 FOR UPDATE NOWAIT", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b23ebe81aaf5872fa5c5ef9bc4fe78d82a985595b149e330ab71e5bef972f301" +} diff --git a/.sqlx/query-b61f46b1ad2ca0dbfbd8a4a15fdd10ccb2e40082ac3e21aadd2a9cbea771b416.json b/.sqlx/query-b61f46b1ad2ca0dbfbd8a4a15fdd10ccb2e40082ac3e21aadd2a9cbea771b416.json new file mode 100644 index 00000000..943268ae --- /dev/null +++ b/.sqlx/query-b61f46b1ad2ca0dbfbd8a4a15fdd10ccb2e40082ac3e21aadd2a9cbea771b416.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE invoice_line_items SET description='Changed line',rate_cents=2400,amount_cents=2400 WHERE invoice_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "b61f46b1ad2ca0dbfbd8a4a15fdd10ccb2e40082ac3e21aadd2a9cbea771b416" +} diff --git a/.sqlx/query-be818e92f62cc3aa80bd46c5e701439151678f91f41b26b61cb7de107144195b.json b/.sqlx/query-be818e92f62cc3aa80bd46c5e701439151678f91f41b26b61cb7de107144195b.json new file mode 100644 index 00000000..72c089ff --- /dev/null +++ b/.sqlx/query-be818e92f62cc3aa80bd46c5e701439151678f91f41b26b61cb7de107144195b.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT setting::bigint AS \"timeout!\" FROM pg_settings WHERE name='lock_timeout'", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "timeout!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "be818e92f62cc3aa80bd46c5e701439151678f91f41b26b61cb7de107144195b" +} diff --git a/.sqlx/query-cfccbd25e4f5eab5cb4dd65d8e34073820ca885382c35bf94e75d5e2edcafeb4.json b/.sqlx/query-cfccbd25e4f5eab5cb4dd65d8e34073820ca885382c35bf94e75d5e2edcafeb4.json new file mode 100644 index 00000000..1b98d790 --- /dev/null +++ b/.sqlx/query-cfccbd25e4f5eab5cb4dd65d8e34073820ca885382c35bf94e75d5e2edcafeb4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE invoices SET number='PRIVATE-2',total_cents=2400 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "cfccbd25e4f5eab5cb4dd65d8e34073820ca885382c35bf94e75d5e2edcafeb4" +} diff --git a/.sqlx/query-f32369e3cdb5421719bdbd47ea5ae07d8f9f38c7fe96de0ec90d171509d5b08d.json b/.sqlx/query-f32369e3cdb5421719bdbd47ea5ae07d8f9f38c7fe96de0ec90d171509d5b08d.json new file mode 100644 index 00000000..e28b52c1 --- /dev/null +++ b/.sqlx/query-f32369e3cdb5421719bdbd47ea5ae07d8f9f38c7fe96de0ec90d171509d5b08d.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ WRITE", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "f32369e3cdb5421719bdbd47ea5ae07d8f9f38c7fe96de0ec90d171509d5b08d" +} diff --git a/.sqlx/query-fcee15572f69a3e3be73825baaade8e35340f56f7b698f6a9eacd18a61dc092e.json b/.sqlx/query-fcee15572f69a3e3be73825baaade8e35340f56f7b698f6a9eacd18a61dc092e.json new file mode 100644 index 00000000..50396d59 --- /dev/null +++ b/.sqlx/query-fcee15572f69a3e3be73825baaade8e35340f56f7b698f6a9eacd18a61dc092e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT set_config('lock_timeout', $1, true)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "set_config", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fcee15572f69a3e3be73825baaade8e35340f56f7b698f6a9eacd18a61dc092e" +} diff --git a/crates/horae/src/reports.rs b/crates/horae/src/reports.rs index 1fed84ab..a57ae96c 100644 --- a/crates/horae/src/reports.rs +++ b/crates/horae/src/reports.rs @@ -421,18 +421,8 @@ fn projects_xlsx(rows: &[ProjectExportRow]) -> Result, StatusCode> { // ── Invoice export ──────────────────────────────────────────────────────────── -/// An invoice and its line items, org-scoped — shared with the `get_invoice` -/// server fn so exports render exactly what the app serves. `None` when the -/// org has no such invoice; each caller maps that to its own not-found error. -pub(crate) async fn fetch_invoice_with_lines( - invoice_id: uuid::Uuid, - org_id: uuid::Uuid, -) -> Result)>, sqlx::Error> { - let state = crate::state::global_state().await; - let mut connection = state.db.acquire().await?; - fetch_invoice_from(&mut connection, invoice_id, org_id).await -} - +/// Shared org-scoped invoice projection. Callers authorize the actor and choose +/// the transaction so metadata and lines belong to the same snapshot. pub(crate) async fn fetch_invoice_from( connection: &mut sqlx::PgConnection, invoice_id: uuid::Uuid, diff --git a/crates/horae/src/server_fns.rs b/crates/horae/src/server_fns.rs index 5471eed6..2698084b 100644 --- a/crates/horae/src/server_fns.rs +++ b/crates/horae/src/server_fns.rs @@ -508,6 +508,8 @@ mod plugins; mod project_creation; mod projects; mod reports; +#[cfg(feature = "server")] +mod snapshot; mod time_entries; mod users; diff --git a/crates/horae/src/server_fns/importers/authorization_tests.rs b/crates/horae/src/server_fns/importers/authorization_tests.rs index 71832047..f05cbdbd 100644 --- a/crates/horae/src/server_fns/importers/authorization_tests.rs +++ b/crates/horae/src/server_fns/importers/authorization_tests.rs @@ -25,6 +25,7 @@ use super::*; mod approval_labels; mod cli; +mod legacy_readers; mod session_identity; mod time_entry_payload; mod user_directory; @@ -126,7 +127,11 @@ impl Api { .iter() .filter(|route| { explicit.map_or_else( - || route.path().contains(&format!("/{name}")), + || { + route.path().split_once(&format!("/{name}")).is_some_and( + |(_, suffix)| suffix.bytes().all(|byte| byte.is_ascii_digit()), + ) + }, |path| route.path() == path, ) }) @@ -283,6 +288,7 @@ async fn job_endpoints_enforce_session_role_and_organization(pool: PgPool) { session_identity::check(&pool, &api).await; user_directory::check(&pool, &api).await; approval_labels::check(&pool, &api).await; + legacy_readers::check(&pool, &api).await; let admin = api.cookie(owner.user_id).await; let expired = api.cookie(owner.user_id).await; assert_eq!( diff --git a/crates/horae/src/server_fns/importers/authorization_tests/legacy_readers.rs b/crates/horae/src/server_fns/importers/authorization_tests/legacy_readers.rs new file mode 100644 index 00000000..739011ed --- /dev/null +++ b/crates/horae/src/server_fns/importers/authorization_tests/legacy_readers.rs @@ -0,0 +1,107 @@ +//! Session-owned legacy readers reauthorize after the initial session lookup. + +use super::*; +use crate::server_fns::test_seed::{seed, time_entry, wait_for_blocked}; +use horae_core::types::EntryState; + +pub(super) async fn check(pool: &PgPool, api: &Api) { + let ids = seed(pool, OrgRole::Manager).await; + let foreign = seed(pool, OrgRole::Admin).await; + time_entry(pool, &ids, EntryState::Open).await; + let invoice = Uuid::now_v7(); + sqlx::query!("INSERT INTO invoices (id,org_id,client_id,number,issued_on,due_on,currency,total_cents) VALUES ($1,$2,$3,'PRIVATE-HTTP','2026-09-07','2026-10-07','EUR',1200)", invoice, ids.org_id, ids.client_id).execute(pool).await.unwrap(); + let cookie = api.cookie(ids.user_id).await; + let foreign_cookie = api.cookie(foreign.user_id).await; + let body = json!({ + "from": "2026-09-07", "to": "2026-09-07", "client_id": null, + "project_id": null, "user_id": null, "tag_id": null, "status": null, + "invoice_id": invoice, "actor_id": foreign.user_id, "org_id": foreign.org_id, + "org_role": "admin" + }); + for name in ["report_detailed", "list_invoices", "get_invoice"] { + assert_eq!( + api.call(name, body.clone(), None, false).await.status(), + StatusCode::UNAUTHORIZED + ); + let expected = api.json(name, body.clone(), &cookie).await; + match name { + "report_detailed" => { + assert_eq!(expected.as_array().unwrap().len(), 1); + assert_eq!(expected[0]["minutes"], 60); + } + "list_invoices" => { + assert_eq!(expected.as_array().unwrap().len(), 1); + assert_eq!(expected[0]["number"], "PRIVATE-HTTP"); + } + _ => assert_eq!(expected["invoice"]["number"], "PRIVATE-HTTP"), + } + let response = api + .call(name, body.clone(), Some(&foreign_cookie), false) + .await; + if name == "get_invoice" { + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert!(!response.text().await.unwrap().contains("PRIVATE-HTTP")); + } else { + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.json::().await.unwrap(), json!([])); + } + for deactivate in [false, true] { + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id = $1 FOR NO KEY UPDATE", + ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + if deactivate { + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&mut *writer) + .await + .unwrap(); + } else { + sqlx::query!( + "UPDATE users SET org_role='member' WHERE id=$1", + ids.user_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + let pid = sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut *writer) + .await + .unwrap(); + // The session gate sees the committed Manager. Its materialized + // reader must wait for and observe this winning revocation. + let (response, ()) = + tokio::join!(api.call(name, body.clone(), Some(&cookie), false), async { + wait_for_blocked(pool, pid).await; + writer.commit().await.unwrap(); + }); + assert_eq!(response.status(), StatusCode::FORBIDDEN, "{name}"); + let error = response.text().await.unwrap(); + for field in ["PRIVATE-HTTP", "total_cents", "project_name", "minutes"] { + assert!(!error.contains(field), "{name}: {error}"); + } + assert_eq!( + api.call(name, body.clone(), Some(&cookie), false) + .await + .status(), + if deactivate { + StatusCode::UNAUTHORIZED + } else { + StatusCode::FORBIDDEN + } + ); + sqlx::query!( + "UPDATE users SET active=true,org_role='manager' WHERE id=$1", + ids.user_id + ) + .execute(pool) + .await + .unwrap(); + assert_eq!(api.json(name, body.clone(), &cookie).await, expected); + } + } +} diff --git a/crates/horae/src/server_fns/invoices.rs b/crates/horae/src/server_fns/invoices.rs index ab2c083a..8f74fcaa 100644 --- a/crates/horae/src/server_fns/invoices.rs +++ b/crates/horae/src/server_fns/invoices.rs @@ -137,7 +137,20 @@ pub async fn list_invoices(status: Option) -> Result, Serve .map(|s| parse_enum(s, "status")) .transpose()?; - fetch_invoices(&state.db, manager.org_id, status_filter, None).await + fetch_list(&state.db, manager.org_id, manager.id, status_filter).await +} + +#[cfg(feature = "server")] +pub(super) async fn fetch_list( + pool: &sqlx::PgPool, + org_id: uuid::Uuid, + actor_id: uuid::Uuid, + status_filter: Option, +) -> Result, ServerFnError> { + let mut tx = super::snapshot::manager(pool, org_id, actor_id).await?; + let invoices = fetch_invoices(&mut *tx, org_id, status_filter, None).await?; + tx.commit().await.map_err(server_err)?; + Ok(invoices) } #[cfg(feature = "server")] @@ -178,11 +191,24 @@ pub async fn get_invoice(invoice_id: String) -> Result Result { + let mut tx = super::snapshot::manager(pool, org_id, actor_id).await?; + let (invoice, lines) = crate::reports::fetch_invoice_from(&mut tx, invoice_id, org_id) .await .map_err(server_err)? .ok_or_else(|| not_found("Invoice not found"))?; + tx.commit().await.map_err(server_err)?; Ok(InvoiceWithLines { invoice, lines }) } diff --git a/crates/horae/src/server_fns/reports.rs b/crates/horae/src/server_fns/reports.rs index f890ec13..001f228a 100644 --- a/crates/horae/src/server_fns/reports.rs +++ b/crates/horae/src/server_fns/reports.rs @@ -184,9 +184,10 @@ pub async fn report_detailed( // The CSV/XLSX exports must return exactly these rows, so the query lives // once in `crate::reports` and both surfaces call it. let state = crate::state::global_state().await; - crate::reports::fetch_entries( + fetch_detailed( &state.db, manager.org_id, + manager.id, (from_date, to_date), crate::reports::ReportFilters { client_id: client_filter, @@ -196,7 +197,22 @@ pub async fn report_detailed( }, ) .await - .map_err(server_err) +} + +#[cfg(feature = "server")] +pub(super) async fn fetch_detailed( + pool: &sqlx::PgPool, + org_id: uuid::Uuid, + actor_id: uuid::Uuid, + period: (chrono::NaiveDate, chrono::NaiveDate), + filters: crate::reports::ReportFilters, +) -> Result, ServerFnError> { + let mut tx = super::snapshot::manager(pool, org_id, actor_id).await?; + let rows = crate::reports::fetch_entries(&mut *tx, org_id, period, filters) + .await + .map_err(server_err)?; + tx.commit().await.map_err(server_err)?; + Ok(rows) } // ── Plugins ──────────────────────────────────────────────────────────────── diff --git a/crates/horae/src/server_fns/snapshot.rs b/crates/horae/src/server_fns/snapshot.rs new file mode 100644 index 00000000..648f9092 --- /dev/null +++ b/crates/horae/src/server_fns/snapshot.rs @@ -0,0 +1,101 @@ +//! Current manager authority for materialized, snapshot-consistent reads. + +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use super::{OrgRole, ServerFnError, conflict, forbidden, server_err}; + +const MAX_ATTEMPTS: usize = 3; + +#[cfg(test)] +mod reader_tests; + +fn database_error(error: sqlx::Error) -> ServerFnError { + tracing::error!(%error, "Unable to authorize financial snapshot"); + server_err("Unable to prepare a consistent snapshot") +} + +async fn authorize( + tx: &mut Transaction<'_, Postgres>, + org_id: Uuid, + actor_id: Uuid, +) -> Result { + match sqlx::query!( + "SELECT id FROM organizations WHERE id = $1 FOR SHARE", + org_id + ) + .fetch_one(&mut **tx) + .await + { + Ok(_) => {} + Err(sqlx::Error::RowNotFound) => return Ok(false), + Err(error) => return Err(error), + } + let role = sqlx::query_scalar!( + r#"SELECT org_role as "role: OrgRole" FROM users WHERE id=$1 AND org_id=$2 AND active FOR SHARE"#, + actor_id, org_id, + ).fetch_optional(&mut **tx).await?; + Ok(role.is_some_and(|role| role.is_manager_or_above())) +} + +/// Use session-derived IDs. Materialize and commit before external/client waits. +/// This preserves the legacy manager boundary, not canonical grant enforcement. +pub(crate) async fn manager( + pool: &PgPool, + org_id: Uuid, + actor_id: Uuid, +) -> Result, ServerFnError> { + for _ in 0..MAX_ATTEMPTS { + let mut tx = pool.begin().await.map_err(database_error)?; + sqlx::query!("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ, READ WRITE") + .execute(&mut *tx) + .await + .map_err(database_error)?; + let previous_timeout = sqlx::query_scalar!( + "SELECT setting::bigint AS \"timeout!\" FROM pg_settings WHERE name='lock_timeout'" + ) + .fetch_one(&mut *tx) + .await + .map_err(database_error)?; + let bounded_timeout = if previous_timeout == 0 { + 5000 + } else { + previous_timeout.min(5000) + }; + sqlx::query!( + "SELECT set_config('lock_timeout', $1, true)", + format!("{bounded_timeout}ms") + ) + .fetch_one(&mut *tx) + .await + .map_err(database_error)?; + match authorize(&mut tx, org_id, actor_id).await { + Ok(true) => { + sqlx::query!( + "SELECT set_config('lock_timeout', $1, true)", + format!("{previous_timeout}ms") + ) + .fetch_one(&mut *tx) + .await + .map_err(database_error)?; + return Ok(tx); + } + Ok(false) => { + tx.rollback().await.map_err(database_error)?; + return Err(forbidden("Active manager access required")); + } + Err(error) => { + tx.rollback().await.map_err(database_error)?; + if error + .as_database_error() + .is_none_or(|error| error.code().as_deref() != Some("40001")) + { + return Err(database_error(error)); + } + // A legacy actor update need not advance the organization row. + // Start a new snapshot; retrying just its query remains stale. + } + } + } + Err(conflict("Access changed while reading. Please try again.")) +} diff --git a/crates/horae/src/server_fns/snapshot/reader_tests.rs b/crates/horae/src/server_fns/snapshot/reader_tests.rs new file mode 100644 index 00000000..ba229368 --- /dev/null +++ b/crates/horae/src/server_fns/snapshot/reader_tests.rs @@ -0,0 +1,435 @@ +use super::*; +use crate::server_fns::{ + invoices, reports, + test_seed::{SeedIds, seed, time_entry, wait_for_blocked}, +}; +use horae_core::types::{EntryState, InvoiceStatus}; +use serde_json::{Value, json}; +use std::{sync::Arc, time::Duration}; + +#[derive(Clone, Copy, Debug)] +enum Reader { + Detailed, + List, + Invoice, +} + +const READERS: [Reader; 3] = [Reader::Detailed, Reader::List, Reader::Invoice]; + +struct Fixture { + ids: SeedIds, + invoice: Uuid, +} + +impl Fixture { + async fn new(pool: &PgPool) -> Self { + let ids = seed(pool, OrgRole::Manager).await; + let entry = time_entry(pool, &ids, EntryState::Open).await; + let invoice = Uuid::now_v7(); + sqlx::query!("INSERT INTO invoices (id,org_id,client_id,number,issued_on,due_on,currency,total_cents) VALUES ($1,$2,$3,'PRIVATE-1','2026-09-07','2026-10-07','EUR',1200)", invoice, ids.org_id, ids.client_id).execute(pool).await.unwrap(); + sqlx::query!("INSERT INTO invoice_line_items (id,invoice_id,time_entry_id,description,minutes,rate_cents,amount_cents) VALUES ($1,$2,$3,'Private line',60,1200,1200)", Uuid::now_v7(), invoice, entry).execute(pool).await.unwrap(); + Self { ids, invoice } + } + + async fn read( + &self, + pool: &PgPool, + org: Uuid, + actor: Uuid, + reader: Reader, + ) -> Result { + Ok(match reader { + Reader::Detailed => json!( + reports::fetch_detailed( + pool, + org, + actor, + ("2026-09-07".parse().unwrap(), "2026-09-07".parse().unwrap()), + crate::reports::ReportFilters::default() + ) + .await? + ), + Reader::List => json!(invoices::fetch_list(pool, org, actor, None).await?), + Reader::Invoice => json!(invoices::fetch_detail(pool, org, actor, self.invoice).await?), + }) + } + + fn spawn( + self: &Arc, + pool: &PgPool, + reader: Reader, + ) -> tokio::task::JoinHandle> { + let fixture = Arc::clone(self); + let pool = pool.clone(); + tokio::spawn(async move { + fixture + .read(&pool, fixture.ids.org_id, fixture.ids.user_id, reader) + .await + }) + } +} + +fn denied(result: Result) { + assert!( + matches!( + result, + Err(ServerFnError::ServerError { + code: super::super::FORBIDDEN, + .. + }) + ), + "{result:?}" + ); +} + +async fn pid(tx: &mut Transaction<'_, Postgres>) -> i32 { + sqlx::query_scalar!("SELECT pg_backend_pid() as \"pid!\"") + .fetch_one(&mut **tx) + .await + .unwrap() +} + +async fn block_payload(tx: &mut Transaction<'_, Postgres>, reader: Reader) { + match reader { + Reader::Detailed => sqlx::query!("LOCK TABLE project_tasks IN ACCESS EXCLUSIVE MODE") + .execute(&mut **tx) + .await + .unwrap(), + Reader::List => sqlx::query!("LOCK TABLE invoices IN ACCESS EXCLUSIVE MODE") + .execute(&mut **tx) + .await + .unwrap(), + Reader::Invoice => sqlx::query!("LOCK TABLE invoice_line_items IN ACCESS EXCLUSIVE MODE") + .execute(&mut **tx) + .await + .unwrap(), + }; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_readers_reject_revoked_session_identity(pool: PgPool) { + let fixture = Fixture::new(&pool).await; + let mut leaks = Vec::new(); + for reader in READERS { + for inactive in [false, true] { + // IDs were captured by the session gate while this actor was Manager. + let role = if inactive { + OrgRole::Manager + } else { + OrgRole::Member + }; + sqlx::query!( + "UPDATE users SET org_role=$2, active=$3 WHERE id=$1", + fixture.ids.user_id, + role as OrgRole, + !inactive + ) + .execute(&pool) + .await + .unwrap(); + let result = fixture + .read(&pool, fixture.ids.org_id, fixture.ids.user_id, reader) + .await; + if result.is_ok() { + leaks.push((reader, inactive)); + } else { + denied(result); + } + } + } + assert!( + leaks.is_empty(), + "Readers disclosed payload after revocation (reader, inactive): {leaks:?}" + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_readers_preserve_actor_tenant_filters_and_missing_resources(pool: PgPool) { + let fixture = Fixture::new(&pool).await; + let foreign = Fixture::new(&pool).await; + for reader in READERS { + fixture + .read(&pool, fixture.ids.org_id, fixture.ids.user_id, reader) + .await + .unwrap(); + for (org, actor) in [ + (fixture.ids.org_id, foreign.ids.user_id), + (fixture.ids.org_id, Uuid::now_v7()), + (Uuid::now_v7(), fixture.ids.user_id), + ] { + denied(fixture.read(&pool, org, actor, reader).await); + } + } + let list = invoices::fetch_list( + &pool, + fixture.ids.org_id, + fixture.ids.user_id, + Some(InvoiceStatus::Sent), + ) + .await + .unwrap(); + assert!(list.is_empty()); + for id in [foreign.invoice, Uuid::now_v7()] { + let result = + invoices::fetch_detail(&pool, fixture.ids.org_id, fixture.ids.user_id, id).await; + assert!( + matches!( + result, + Err(ServerFnError::ServerError { + code: super::super::NOT_FOUND, + .. + }) + ), + "{result:?}" + ); + } + let rows = reports::fetch_detailed( + &pool, + fixture.ids.org_id, + fixture.ids.user_id, + ("2026-09-07".parse().unwrap(), "2026-09-07".parse().unwrap()), + crate::reports::ReportFilters { + project_id: Some(foreign.ids.project_id), + ..Default::default() + }, + ) + .await + .unwrap(); + assert!(rows.is_empty()); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_readers_deny_writer_first_revocation(pool: PgPool) { + for reader in READERS { + for gated in [false, true] { + for deactivate in [false, true] { + let fixture = Arc::new(Fixture::new(&pool).await); + let mut writer = pool.begin().await.unwrap(); + if gated { + sqlx::query!( + "SELECT id FROM organizations WHERE id = $1 FOR NO KEY UPDATE", + fixture.ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + } + if deactivate { + sqlx::query!( + "UPDATE users SET active=false WHERE id=$1", + fixture.ids.user_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } else { + sqlx::query!( + "UPDATE users SET org_role='member' WHERE id=$1", + fixture.ids.user_id + ) + .execute(&mut *writer) + .await + .unwrap(); + } + let blocker = pid(&mut writer).await; + let pending = fixture.spawn(&pool, reader); + wait_for_blocked(&pool, blocker).await; + writer.commit().await.unwrap(); + denied( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap(), + ); + } + } + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_readers_hold_reader_first_authority_until_materialized(pool: PgPool) { + for reader in READERS { + let fixture = Arc::new(Fixture::new(&pool).await); + let expected = fixture + .read(&pool, fixture.ids.org_id, fixture.ids.user_id, reader) + .await + .unwrap(); + let mut blocker = pool.begin().await.unwrap(); + block_payload(&mut blocker, reader).await; + let blocker_pid = pid(&mut blocker).await; + let pending = fixture.spawn(&pool, reader); + wait_for_blocked(&pool, blocker_pid).await; + let reader_pid = sqlx::query_scalar!("SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid))", blocker_pid).fetch_one(&pool).await.unwrap().unwrap(); + let revoke = { + let pool = pool.clone(); + let actor = fixture.ids.user_id; + tokio::spawn(async move { + sqlx::query!("UPDATE users SET active=false WHERE id=$1", actor) + .execute(&pool) + .await + .unwrap(); + }) + }; + wait_for_blocked(&pool, reader_pid).await; + blocker.commit().await.unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap() + .unwrap(), + expected + ); + tokio::time::timeout(Duration::from_secs(5), revoke) + .await + .unwrap() + .unwrap(); + denied( + fixture + .read(&pool, fixture.ids.org_id, fixture.ids.user_id, reader) + .await, + ); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_invoice_detail_keeps_metadata_and_lines_in_one_snapshot(pool: PgPool) { + let fixture = Arc::new(Fixture::new(&pool).await); + let expected = fixture + .read( + &pool, + fixture.ids.org_id, + fixture.ids.user_id, + Reader::Invoice, + ) + .await + .unwrap(); + let mut writer = pool.begin().await.unwrap(); + block_payload(&mut writer, Reader::Invoice).await; + let blocker = pid(&mut writer).await; + let pending = fixture.spawn(&pool, Reader::Invoice); + wait_for_blocked(&pool, blocker).await; + sqlx::query!( + "UPDATE invoices SET number='PRIVATE-2',total_cents=2400 WHERE id=$1", + fixture.invoice + ) + .execute(&mut *writer) + .await + .unwrap(); + sqlx::query!("UPDATE invoice_line_items SET description='Changed line',rate_cents=2400,amount_cents=2400 WHERE invoice_id=$1", fixture.invoice).execute(&mut *writer).await.unwrap(); + writer.commit().await.unwrap(); + assert_eq!(pending.await.unwrap().unwrap(), expected); + let fresh = fixture + .read( + &pool, + fixture.ids.org_id, + fixture.ids.user_id, + Reader::Invoice, + ) + .await + .unwrap(); + assert_eq!(fresh["invoice"]["total_cents"], 2400); + assert_eq!(fresh["lines"][0]["amount_cents"], 2400); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_readers_cancel_without_retaining_authority_or_pool_connection(pool: PgPool) { + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with((*pool.connect_options()).clone()) + .await + .unwrap(); + for reader in READERS { + let fixture = Arc::new(Fixture::new(&pool).await); + let mut blocker = pool.begin().await.unwrap(); + block_payload(&mut blocker, reader).await; + let blocker_pid = pid(&mut blocker).await; + let pending = fixture.spawn(&reader_pool, reader); + wait_for_blocked(&pool, blocker_pid).await; + pending.abort(); + assert!(pending.await.unwrap_err().is_cancelled()); + blocker.rollback().await.unwrap(); + tokio::time::timeout( + Duration::from_secs(5), + fixture.read( + &reader_pool, + fixture.ids.org_id, + fixture.ids.user_id, + reader, + ), + ) + .await + .unwrap() + .unwrap(); + let mut writer = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR UPDATE NOWAIT", + fixture.ids.org_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + sqlx::query!( + "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + fixture.ids.user_id + ) + .fetch_one(&mut *writer) + .await + .unwrap(); + writer.rollback().await.unwrap(); + } + reader_pool.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_readers_preserve_inherited_connection_settings(pool: PgPool) { + let fixture = Fixture::new(&pool).await; + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with((*pool.connect_options()).clone()) + .await + .unwrap(); + for isolation in ["read committed", "repeatable read", "serializable"] { + sqlx::query!("SELECT set_config('default_transaction_isolation', $1, false) AS isolation, set_config('default_transaction_read_only', 'on', false) AS read_only, set_config('lock_timeout', '250ms', false) AS timeout", isolation).fetch_one(&reader_pool).await.unwrap(); + for reader in READERS { + let expected = fixture + .read(&pool, fixture.ids.org_id, fixture.ids.user_id, reader) + .await + .unwrap(); + assert_eq!( + fixture + .read( + &reader_pool, + fixture.ids.org_id, + fixture.ids.user_id, + reader + ) + .await + .unwrap(), + expected + ); + denied( + fixture + .read(&reader_pool, fixture.ids.org_id, Uuid::now_v7(), reader) + .await, + ); + } + let settings = sqlx::query!("SELECT current_setting('transaction_isolation') AS \"isolation!\", current_setting('transaction_read_only') AS \"read_only!\", current_setting('lock_timeout') AS \"timeout!\"").fetch_one(&reader_pool).await.unwrap(); + assert_eq!( + ( + settings.isolation.as_str(), + settings.read_only.as_str(), + settings.timeout.as_str() + ), + (isolation, "on", "250ms") + ); + } + reader_pool.close().await; +} diff --git a/specs/015-scoped-permissions/legacy-reader-authority.md b/specs/015-scoped-permissions/legacy-reader-authority.md new file mode 100644 index 00000000..1e5b9e89 --- /dev/null +++ b/specs/015-scoped-permissions/legacy-reader-authority.md @@ -0,0 +1,87 @@ +# Legacy report and invoice reader authority + +Started at `60f60f9`, then rebased and verified against `5faed76` on +`feat/scoped-permissions`, dependency of draft #212. This increment addresses the +remaining legacy materialized-reader revocation work in T014/T039; it is not a newly +introduced regression or canonical policy activation. + +## Scope and plan + +1. Extract the production detailed-report, invoice-list and invoice-detail + readers without changing their queries. Demonstrate revoked-actor disclosure + with disposable PostgreSQL fixtures before adding authority fences. +1. Reuse `server_fns::snapshot::manager`: trusted organization/actor IDs, + organization then actor SHARE locks, explicit REPEATABLE READ / READ WRITE, + bounded prelude retries and existing timeout restoration. Materialize and + commit before returning. Read invoice metadata and lines in that same snapshot. +1. Exercise current identity, tenant isolation, both race orders, coherent + payloads, inherited settings and cancellation; extend the real-session HTTP + harness for these endpoints. Run SQLx preparation, focused/full server tests, + offline Clippy, WASM and formatting in an isolated target and database. + +The READ COMMITTED project-export/CSV contracts remain unchanged: these three +readers follow the existing manager-snapshot contract. Preserve legacy +Manager/Admin authorization, filters, ordering, exact values, missing-invoice +404 and unrestricted accepted result sizes. Do not infer mixed-project invoice +policy, activate policy 1, alter UI, or write business data from these readers. + +## Historical verification on the source branch + +RED: `legacy_readers_reject_revoked_session_identity` failed with payloads from +all three production readers after both demotion and deactivation (six cases). +The test used the extracted existing queries before adding transaction fences. + +RED: `legacy_invoice_detail_keeps_metadata_and_lines_in_one_snapshot` reproduced +an invoice header with 1,200 cents alongside concurrently updated 2,400-cent +lines. Both reproductions used the same pre-fix test binary and disposable data. + +GREEN: all seven `server_fns::snapshot::reader_tests` pass, covering current +identity, tenant/resource filtering, gated/direct writer-first revocation, +reader-first retention, invoice coherence, cancellation with a one-connection +pool and inherited READ ONLY/isolation settings. + +The HTTP harness's substring route lookup matched both `get_invoice` and +`get_invoice_editor`; it now accepts only the function name followed by Dioxus's +numeric route hash. The real-session matrix passes for all three endpoints, +including missing sessions, forged identity fields, foreign resources and both +demotion and inactivity committed after the initial session lookup. + +`cargo test -p horae --features server --quiet` after rebasing: 1,110 in-crate tests +and 221 external tests pass; zero failures, 11 existing ignored tests. All commands run +inside the Nix dev shell against a dedicated PostgreSQL 17 cluster using a +private UNIX socket. The target is isolated, with debug symbols and incremental +compilation disabled and two build jobs. + +Complete `cargo sqlx prepare --workspace -- --features server --all-targets` +passes after cleaning only this target's Horae package artifacts. The cache has +1,473 descriptors: all 1,465 existing entries plus eight new test queries, with +no deletions. After rebasing, offline all-target server Clippy and WASM Clippy +pass with `-D warnings`, with `DATABASE_URL` unset. Repository formatting passes. + +No real database or Harvest account is used. Full `nix flake check` and +deployment/browser acceptance remain the parent feature's pre-merge gates; +this branch does not claim policy activation +or complete permission parity. + +## Independent extraction after the Clients MVP + +The replacement delivery is based on master `02f7b58`, the merge of #216. +It preserves that delivery's optional client filter and stable invoice ordering, +wrapping the current shared query rather than copying the older list query. +The snapshot helper's organization SHARE query is inlined unchanged so the +repair does not depend on unrelated project-writer changes. Original branches +and evidence remain preserved; the separation ledger is maintained in #218. + +The new branch's seven focused reader regressions passed. Its full workspace +server/core suite then passed 1,127 tests, with zero failures and 11 pre-existing +manual scale/stress cases ignored. This includes the real-session HTTP matrix, +all seven reader regressions and the 35 Clients tests delivered by #216. + +Full SQLx preparation with `--workspace -- --features server --all-targets` +passed against a fresh private PostgreSQL cluster: all 1,003 base descriptors +were retained unchanged and 18 were added for the extracted helper/tests. +Offline all-target server Clippy and WASM Clippy passed with warnings denied +and performance lints, with `DATABASE_URL` unset. `nix fmt -- --ci` passed. +Required new-head Flake Check remains pending. Historical source-branch gates +above are not evidence that this extraction has passed that gate. No production +data or policy changed.