From d3c12af3d46f221f3b8c072abc44a10fa133ba0f Mon Sep 17 00:00:00 2001 From: Aldo Borrero <82811+aldoborrero@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:47:00 +0200 Subject: [PATCH 1/6] Expose administrator permission change history --- crates/horae/src/components/admin_shell.rs | 39 +- .../src/components/permission_description.rs | 13 +- crates/horae/src/models.rs | 1 - crates/horae/src/models/permission_audit.rs | 165 ++- crates/horae/src/pages.rs | 1 + crates/horae/src/pages/permission_audit.rs | 258 ++++ .../src/pages/settings/own_permissions.rs | 1 + crates/horae/src/route.rs | 3 + crates/horae/src/server_fns/auth.rs | 64 + .../importers/authorization_tests.rs | 2 + .../authorization_tests/permission_audit.rs | 456 +++++++ .../authorization_tests/project_managers.rs | 14 + crates/horae/src/server_fns/permissions.rs | 5 + .../horae/src/server_fns/permissions/audit.rs | 282 +++++ .../src/server_fns/permissions/tests/audit.rs | 1064 +++++++++++++++++ .../permissions/tests/audit/browsing.rs | 366 ++++++ crates/horae/tests/admin_shell.rs | 95 +- crates/horae/tests/permission_audit_ui.rs | 395 ++++++ 18 files changed, 3211 insertions(+), 13 deletions(-) create mode 100644 crates/horae/src/pages/permission_audit.rs create mode 100644 crates/horae/src/server_fns/importers/authorization_tests/permission_audit.rs create mode 100644 crates/horae/src/server_fns/permissions/audit.rs create mode 100644 crates/horae/src/server_fns/permissions/tests/audit.rs create mode 100644 crates/horae/src/server_fns/permissions/tests/audit/browsing.rs create mode 100644 crates/horae/tests/permission_audit_ui.rs diff --git a/crates/horae/src/components/admin_shell.rs b/crates/horae/src/components/admin_shell.rs index 06f4ce0f..b24201fc 100644 --- a/crates/horae/src/components/admin_shell.rs +++ b/crates/horae/src/components/admin_shell.rs @@ -1,4 +1,5 @@ use dioxus::prelude::*; +use horae_core::permissions::catalog::PERMISSION_CATALOG_VERSION; use crate::pages::timesheet::{Anchor, CalSpan, ViewMode}; use crate::route::Route; @@ -10,20 +11,38 @@ use crate::server_fns; /// design's admin settings shell). Admin-only; non-admins get a short notice. /// /// Only sections with a real destination are listed — People (user management) -/// and Importers (Harvest). Roles & permissions, General, Export & backups, and -/// Audit log from the design are deferred until they have a backend. +/// Importers (Harvest), and the permission audit log. Other sections are deferred +/// until they have a backend. #[component] pub fn AdminShell() -> Element { - let mut me = use_resource(|| async move { server_fns::get_me().await }); + let audit = matches!(use_route::(), Route::PermissionAudit {}); + let mut me = use_resource(use_reactive!(|audit| async move { + // Only this reader has moved to canonical Administrator authority. + let allowed = if audit { + server_fns::get_my_permissions().await.map(|own| { + own.is_some_and(|own| { + own.catalog_version == PERMISSION_CATALOG_VERSION && own.is_administrator + }) + }) + } else { + server_fns::get_me().await.map(|user| user.is_admin()) + }; + (audit, allowed) + })); // Mount the workspace and its outlet only after authorization resolves. // Server functions still independently enforce access to every operation. - match (&*me.read(), me.state()() == UseResourceState::Ready) { - (Some(Ok(user)), true) if user.is_admin() => rsx! { AdminWorkspace {} }, - (Some(Ok(_)), true) => rsx! { + let response = me.read(); + let current = response + .as_ref() + .filter(|(requested, _)| *requested == audit) + .map(|(_, result)| result); + match (current, me.state()() == UseResourceState::Ready) { + (Some(Ok(true)), true) => rsx! { AdminWorkspace {} }, + (Some(Ok(false)), true) => rsx! { div { class: "card flex flex-col items-start gap-3 max-w-md", h1 { class: "page-title", "Admins only" } - p { class: "text-secondary", "You need an admin role to manage the workspace." } + p { class: "text-secondary", "You need Administrator access to view this workspace section." } Link { to: Route::Timesheet { view: ViewMode::Week, date: Anchor::default(), span: CalSpan::default() }, class: "btn btn-secondary", @@ -33,7 +52,10 @@ pub fn AdminShell() -> Element { }, (Some(Err(error)), true) => rsx! { div { class: "card flex flex-col items-start gap-3 max-w-md", - div { class: "alert alert-danger", role: "alert", "{error}" } + div { class: "alert alert-danger", role: "alert", + if audit { "Could not verify Administrator access. Sign in again or retry." } + else { "{error}" } + } button { class: "btn btn-secondary", r#type: "button", @@ -89,6 +111,7 @@ fn AdminWorkspace() -> Element { div { class: "adm-group-label", "Data" } nav { class: "flex flex-col gap-1 mb-5", AdmLink { to: Route::HarvestImport {}, label: "Importers" } + AdmLink { to: Route::PermissionAudit {}, label: "Audit log" } } } div { class: "adm-main flex-1 min-w-0", diff --git a/crates/horae/src/components/permission_description.rs b/crates/horae/src/components/permission_description.rs index 998a029c..f9a054e2 100644 --- a/crates/horae/src/components/permission_description.rs +++ b/crates/horae/src/components/permission_description.rs @@ -1,6 +1,17 @@ //! Shared presentation copy; these labels never authorize an operation. -use horae_core::permissions::catalog::Permission; +use horae_core::permissions::catalog::{BuiltInProfile, Permission}; + +pub fn profile_label(profile: BuiltInProfile) -> &'static str { + match profile { + BuiltInProfile::Member => "Member", + BuiltInProfile::ProjectManager => "Project Manager", + BuiltInProfile::PeopleAdmin => "People Admin", + BuiltInProfile::Accounting => "Accounting", + BuiltInProfile::ExecutiveManager => "Executive Manager", + BuiltInProfile::Administrator => "Administrator", + } +} pub fn permission_description(permission: Permission) -> &'static str { use Permission::*; diff --git a/crates/horae/src/models.rs b/crates/horae/src/models.rs index 803f53fc..c91ec2cd 100644 --- a/crates/horae/src/models.rs +++ b/crates/horae/src/models.rs @@ -5,7 +5,6 @@ pub mod invoice; mod jobs; pub mod organization; pub mod own_permissions; -#[cfg(feature = "server")] pub(crate) mod permission_audit; pub(crate) mod permission_editor; #[cfg(feature = "server")] diff --git a/crates/horae/src/models/permission_audit.rs b/crates/horae/src/models/permission_audit.rs index 8f8f58c3..3b1117f2 100644 --- a/crates/horae/src/models/permission_audit.rs +++ b/crates/horae/src/models/permission_audit.rs @@ -1,8 +1,135 @@ -//! Historical relationship facts retained by permission-change receipts. +//! Historical permission-change projection. Never use this DTO as current authority. -use serde::{Deserialize, Serialize}; +use chrono::{DateTime, Utc}; +use horae_core::permissions::catalog::{BuiltInProfile, Permission}; +use serde::{Deserialize, Deserializer, Serialize}; use uuid::Uuid; +use super::permission_editor::PermissionRequester; + +/// An exclusive descending ordering bound, not a receipt lookup or authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditCursor { + pub created_at: DateTime, + pub id: Uuid, +} + +/// A bounded historical page admitted under the requester's current authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuditPage { + pub requester: PermissionRequester, + pub entries: Vec, + pub next_after: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum AuditPrincipal { + User { + user_id: Uuid, + }, + Operator { + invocation_id: String, + command: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AuditEntry { + pub id: Uuid, + pub actor: AuditPrincipal, + pub created_at: DateTime, + pub audit: HistoricalAudit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", content = "details", rename_all = "snake_case")] +pub enum HistoricalAudit { + Template(TemplateAudit), + Profile(ProfileAudit), + ProjectManagers(ProjectAudit), +} + +// These are versioned historical wire types, not deserializable authority models. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TemplateAudit { + pub previous_access_revision: i64, + pub access_revision: i64, + #[serde(deserialize_with = "required_option")] + pub before: Option, + #[serde(deserialize_with = "required_option")] + pub after: Option, + pub detached_people: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TemplateSnapshot { + pub id: Uuid, + pub name: String, + pub catalog_version: i32, + pub grants: Vec, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DetachedPerson { + pub user_id: Uuid, + pub catalog_version: i32, + pub grants: Vec, + pub is_administrator: bool, + pub previous_template_id: Uuid, + pub previous_applied_revision: i64, + pub previous_revision: i64, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileAudit { + pub user_id: Uuid, + pub previous_access_revision: i64, + pub access_revision: i64, + #[serde(deserialize_with = "required_option")] + pub change: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileChange { + pub catalog_version: u32, + pub before: PersonSnapshot, + pub after: PersonSnapshot, + pub removed_projects: Vec, + pub removed_people: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PersonSnapshot { + pub grants: Vec, + pub is_administrator: bool, + pub source: HistoricalSource, + pub revision: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum HistoricalSource { + BuiltIn(BuiltInProfile), + Template { id: Uuid, applied_revision: i64 }, + Individual, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct RemovedRelationship { @@ -10,3 +137,37 @@ pub struct RemovedRelationship { pub subject_id: Uuid, pub revision: i64, } + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectAudit { + pub project_id: Uuid, + pub previous_access_revision: i64, + pub access_revision: i64, + #[serde(deserialize_with = "required_option")] + pub change: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProjectChange { + pub added: Vec, + pub removed: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Designation { + pub id: Uuid, + pub manager_id: Uuid, + pub revision: i64, +} + +fn required_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: Deserialize<'de>, +{ + // A missing field must not be treated as an explicit unchanged outcome. + Option::::deserialize(deserializer) +} diff --git a/crates/horae/src/pages.rs b/crates/horae/src/pages.rs index eb561204..7fe6f2b9 100644 --- a/crates/horae/src/pages.rs +++ b/crates/horae/src/pages.rs @@ -10,6 +10,7 @@ pub mod gallery; pub mod importers; pub mod invoices; pub mod new_project; +pub mod permission_audit; pub mod projects; pub mod reports; pub mod settings; diff --git a/crates/horae/src/pages/permission_audit.rs b/crates/horae/src/pages/permission_audit.rs new file mode 100644 index 00000000..49dd86b6 --- /dev/null +++ b/crates/horae/src/pages/permission_audit.rs @@ -0,0 +1,258 @@ +use dioxus::prelude::*; +use horae_core::permissions::catalog::Permission; + +use crate::components::permission_description::{permission_description, profile_label}; +use crate::models::permission_audit::*; +use crate::models::permission_editor::PermissionRequester; +use crate::server_fns; + +const FORBIDDEN: u16 = 403; +const UNAUTHORIZED: u16 = 401; +const REFRESH_ID: &str = "audit-refresh"; +const NEWEST_ID: &str = "audit-newest"; +const OLDER_ID: &str = "audit-older"; + +#[component] +pub fn PermissionAudit() -> Element { + let mut after = use_signal(|| None::); + let mut requester = use_signal(|| None::); + let mut history = use_resource(move || { + let cursor = after(); + let expected = *requester.peek(); + async move { + let result = server_fns::list_permission_audit(cursor, expected) + .await + .and_then(|page| { + if expected.is_some_and(|identity| identity != page.requester) { + return Err(ServerFnError::ServerError { + code: FORBIDDEN, + message: "The active account changed.".into(), + details: None, + }); + } + if expected.is_none() { + requester.set(Some(page.requester)); + } + Ok(page) + }); + (cursor, result) + } + }); + let response = history.read(); + let pending = history.state()() != UseResourceState::Ready + || response + .as_ref() + .is_none_or(|(cursor, _)| *cursor != after()); + let current = response + .as_ref() + .filter(|_| !pending) + .map(|(_, result)| result); + let next = current + .and_then(|result| result.as_ref().ok()) + .and_then(|page| page.next_after); + + rsx! { + section { aria_labelledby: "audit-title", class: "min-w-0", + div { class: "flex flex-wrap items-center justify-between gap-3 mb-2", + h1 { id: "audit-title", class: "text-2xl font-semibold", "Audit log" } + button { id: REFRESH_ID, r#type: "button", class: "btn btn-secondary btn-sm", disabled: pending, + onclick: move |_| { + if history.state()() == UseResourceState::Ready { history.restart(); } + }, + "Refresh history" + } + } + p { class: "text-sm text-secondary mb-5", + "Permission changes, newest first. Expand an event to see its recorded details. Times are shown in UTC." + } + if pending { + p { class: "text-sm text-secondary", role: "status", "Loading permission history…" } + } else if let Some(Ok(page)) = current { + if page.entries.is_empty() { + p { class: "text-sm text-secondary", role: "status", + if after().is_some() { "No older permission events." } + else { "No permission events recorded yet. Changes to profiles and project managers will appear here." } + } + } else { + div { class: "table-container rounded-xl bg-secondary", + table { aria_label: "Permission change history", + thead { tr { + th { scope: "col", "When" } + th { scope: "col", "Event" } + th { scope: "col", "Actor" } + } } + tbody { + for entry in &page.entries { + tr { key: "{entry.id}", + td { class: "text-xs font-mono text-secondary", + time { datetime: entry.created_at.to_rfc3339(), {entry.created_at.format("%Y-%m-%d %H:%M:%S UTC").to_string()} } + } + td { class: "wrap-anywhere", + details { + summary { class: "cursor-pointer", "{event_label(&entry.audit)}" } + div { class: "text-sm mt-3 flex flex-col gap-3", + p { class: "text-xs text-secondary", "Receipt: {entry.id}" } + {audit_details(&entry.audit)} + } + } + } + td { class: "text-xs text-secondary wrap-anywhere", {principal(&entry.actor)} } + } + } + } + } + } + if next.is_none() { + p { class: "text-sm text-secondary mt-3", role: "status", "No more permission events." } + } + } + } else if let Some(Err(error)) = current { + p { class: "text-sm text-danger", role: "alert", "{error_message(error)}" } + } + div { class: "flex flex-wrap gap-3 mt-4", + button { id: NEWEST_ID, r#type: "button", class: "btn btn-secondary btn-sm", + disabled: pending || after().is_none(), + onclick: move |_| { + if history.state()() == UseResourceState::Ready && after.peek().is_some() { after.set(None); } + }, "Newest events" + } + button { id: OLDER_ID, r#type: "button", class: "btn btn-secondary btn-sm", + disabled: pending || next.is_none(), + onclick: move |_| { + if history.state()() == UseResourceState::Ready && let Some(cursor) = next { + after.set(Some(cursor)); + } + }, "Older events" + } + } + } + } +} + +fn error_message(error: &ServerFnError) -> &'static str { + match error { + ServerFnError::ServerError { + code: UNAUTHORIZED, .. + } => "Sign in again to view permission history.", + ServerFnError::ServerError { + code: FORBIDDEN, .. + } => { + "Administrator access is required for permission history. If you changed accounts, reopen this page." + } + _ => "Could not load permission history. Refresh history to try again.", + } +} + +fn event_label(audit: &HistoricalAudit) -> &'static str { + match audit { + HistoricalAudit::Profile(audit) if audit.change.is_none() => "No permission change", + HistoricalAudit::Profile(_) => "Person permissions changed", + HistoricalAudit::ProjectManagers(audit) if audit.change.is_none() => { + "No project manager change" + } + HistoricalAudit::ProjectManagers(_) => "Project managers changed", + HistoricalAudit::Template(audit) => match (&audit.before, &audit.after) { + (None, Some(_)) => "Custom profile created", + (Some(_), None) => "Custom profile deleted", + (before, after) if before == after => "No custom profile change", + _ => "Custom profile changed", + }, + } +} + +fn principal(actor: &AuditPrincipal) -> Element { + match actor { + AuditPrincipal::User { user_id } => rsx! { "User: {user_id}" }, + AuditPrincipal::Operator { + invocation_id, + command, + } => rsx! { + p { "Operator: {command}" } + p { "Invocation: {invocation_id}" } + }, + } +} + +fn grants_list(grants: &[Permission]) -> Element { + rsx! { + if grants.is_empty() { p { "No configured permissions" } } + else { ul { class: "pl-5", for grant in grants { li { "{permission_description(*grant)}" } } } } + } +} + +fn person_snapshot(label: &str, snapshot: &PersonSnapshot) -> Element { + let source = match &snapshot.source { + HistoricalSource::BuiltIn(profile) => profile_label(*profile).to_owned(), + HistoricalSource::Template { + id, + applied_revision, + } => format!("Custom profile {id}, applied revision {applied_revision}"), + HistoricalSource::Individual => "Individual configuration".into(), + }; + rsx! { + div { + p { class: "font-semibold", "{label}" } + p { "Source: {source}" } + p { "Administrator: " if snapshot.is_administrator { "Yes" } else { "No" } } + p { "Person revision: {snapshot.revision}" } + {grants_list(&snapshot.grants)} + } + } +} + +fn template_snapshot(label: &str, snapshot: &TemplateSnapshot) -> Element { + rsx! { + div { + p { class: "font-semibold", "{label}: {snapshot.name}" } + p { "Profile: {snapshot.id}" } + p { "Revision: {snapshot.revision}; permission catalog: {snapshot.catalog_version}" } + {grants_list(&snapshot.grants)} + } + } +} + +fn audit_details(audit: &HistoricalAudit) -> Element { + match audit { + HistoricalAudit::Profile(audit) => rsx! { + p { "Person: {audit.user_id}" } + p { "Access revision: {audit.previous_access_revision} → {audit.access_revision}" } + if let Some(change) = &audit.change { + p { "Permission catalog: {change.catalog_version}" } + {person_snapshot("Before", &change.before)} + {person_snapshot("After", &change.after)} + for relation in &change.removed_projects { + p { "Removed managed project: {relation.subject_id} (relationship {relation.id}, revision {relation.revision})" } + } + for relation in &change.removed_people { + p { "Removed managed person: {relation.subject_id} (relationship {relation.id}, revision {relation.revision})" } + } + } + }, + HistoricalAudit::Template(audit) => rsx! { + p { "Access revision: {audit.previous_access_revision} → {audit.access_revision}" } + if let Some(before) = &audit.before { {template_snapshot("Before", before)} } + if let Some(after) = &audit.after { {template_snapshot("After", after)} } + for person in &audit.detached_people { + div { + p { class: "font-semibold", "Detached person: {person.user_id}" } + p { "Source: custom profile {person.previous_template_id}, applied revision {person.previous_applied_revision} → individual configuration" } + p { "Person revision: {person.previous_revision} → {person.revision}; permission catalog: {person.catalog_version}" } + p { "Retained Administrator: " if person.is_administrator { "Yes" } else { "No" } } + {grants_list(&person.grants)} + } + } + }, + HistoricalAudit::ProjectManagers(audit) => rsx! { + p { "Project: {audit.project_id}" } + p { "Access revision: {audit.previous_access_revision} → {audit.access_revision}" } + if let Some(change) = &audit.change { + for manager in &change.added { + p { "Added manager: {manager.manager_id} (designation {manager.id}, revision {manager.revision})" } + } + for manager in &change.removed { + p { "Removed manager: {manager.manager_id} (designation {manager.id}, revision {manager.revision})" } + } + } + }, + } +} diff --git a/crates/horae/src/pages/settings/own_permissions.rs b/crates/horae/src/pages/settings/own_permissions.rs index a7d0837f..c356f9ef 100644 --- a/crates/horae/src/pages/settings/own_permissions.rs +++ b/crates/horae/src/pages/settings/own_permissions.rs @@ -69,6 +69,7 @@ fn permission_content( } if own.is_administrator { p { class: "text-sm font-semibold mb-4", "Administrator access" } + a { class: "inline-flex mb-4", href: "/admin/audit", "View permission audit log" } } h3 { class: "text-lg mb-2", "Configured permissions" } p { class: "text-sm text-secondary mb-4", diff --git a/crates/horae/src/route.rs b/crates/horae/src/route.rs index f1554188..de913f01 100644 --- a/crates/horae/src/route.rs +++ b/crates/horae/src/route.rs @@ -11,6 +11,7 @@ use crate::pages::{ importers::HarvestImport, invoices::{InvoiceDetail, InvoiceList, NewInvoiceForClient}, new_project::{EditProject, NewProject, NewProjectForClient}, + permission_audit::PermissionAudit, projects::{ProjectDetail, ProjectList, ProjectsForClient}, reports::Reports, settings::Settings, @@ -83,6 +84,8 @@ pub enum Route { AdminUsers {}, #[route("/admin/importers")] HarvestImport {}, + #[route("/admin/audit")] + PermissionAudit {}, #[end_layout] #[route("/settings")] Settings {}, diff --git a/crates/horae/src/server_fns/auth.rs b/crates/horae/src/server_fns/auth.rs index 8e5d5070..f018f29e 100644 --- a/crates/horae/src/server_fns/auth.rs +++ b/crates/horae/src/server_fns/auth.rs @@ -31,6 +31,70 @@ pub async fn get_me() -> Result }) } +/// Read one permission-change record under the session's current Administrator authority. +/// Missing and foreign receipt IDs are indistinguishable; snapshots confer no authority. +#[server] +pub async fn get_permission_audit( + receipt_id: uuid::Uuid, +) -> Result, ServerFnError> { + let user = require_user().await.map_err(|error| match error { + error @ ServerFnError::ServerError { + code: UNAUTHORIZED, .. + } => error, + error => { + tracing::error!(%error, "Unable to authenticate permission history request"); + server_err("Permission history is unavailable") + } + })?; + let state = crate::state::global_state().await; + super::permissions::audit::read(&state.db, user.org_id, user.id, receipt_id) + .await + .map_err(|error| match error { + super::permissions::audit::AuditReadError::Forbidden => { + forbidden("Current administrator authority is required") + } + error => { + tracing::error!(%error, "Unable to load permission history"); + server_err("Permission history is unavailable") + } + }) +} + +/// Browse bounded permission history under current explicit Administrator authority. +#[server] +pub async fn list_permission_audit( + after: Option, + expected_requester: Option, +) -> Result { + let user = require_user().await.map_err(|error| match error { + error @ ServerFnError::ServerError { + code: UNAUTHORIZED, .. + } => error, + error => { + tracing::error!(%error, "Unable to authenticate permission history request"); + server_err("Permission history is unavailable") + } + })?; + let state = crate::state::global_state().await; + super::permissions::audit::page( + &state.db, + user.org_id, + user.id, + after.as_ref(), + expected_requester, + ) + .await + .map_err(|error| match error { + super::permissions::audit::AuditReadError::Forbidden => { + forbidden("Current administrator authority is required") + } + error => { + tracing::error!(%error,"Unable to load permission history"); + server_err("Permission history is unavailable") + } + }) +} + /// Explain the session person's scoped permissions, or None before activation. /// This display snapshot cannot authorize later requests or select another user. #[server] diff --git a/crates/horae/src/server_fns/importers/authorization_tests.rs b/crates/horae/src/server_fns/importers/authorization_tests.rs index 8f7b48eb..67b5c6d3 100644 --- a/crates/horae/src/server_fns/importers/authorization_tests.rs +++ b/crates/horae/src/server_fns/importers/authorization_tests.rs @@ -29,6 +29,7 @@ mod exports; mod financial_snapshots; mod legacy_readers; mod own_permissions; +mod permission_audit; mod project_managers; mod session_identity; mod time_entry_payload; @@ -359,6 +360,7 @@ async fn job_endpoints_enforce_session_role_and_organization(pool: PgPool) { exports::check(&pool, &api).await; project_managers::check(&pool, &api).await; own_permissions::check(&pool, &api).await; + permission_audit::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/permission_audit.rs b/crates/horae/src/server_fns/importers/authorization_tests/permission_audit.rs new file mode 100644 index 00000000..f7192b9e --- /dev/null +++ b/crates/horae/src/server_fns/importers/authorization_tests/permission_audit.rs @@ -0,0 +1,456 @@ +//! Exercise the registered permission-history route using real session cookies. + +use super::*; +use crate::server_fns::permissions::templates::{self, TemplateAction, TemplateCommand}; +use horae_core::permissions::catalog::BuiltInProfile; + +pub(super) async fn check(pool: &PgPool, api: &Api) { + let ids = crate::server_fns::test_seed::seed(pool, OrgRole::Member).await; + let foreign = crate::server_fns::test_seed::seed(pool, OrgRole::Admin).await; + let cookie = api.cookie(ids.user_id).await; + let name = "get_permission_audit"; + let missing = json!({"receipt_id": Uuid::now_v7()}); + assert_eq!( + api.call(name, missing.clone(), None, false).await.status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + api.call(name, missing.clone(), Some(&cookie), false) + .await + .status(), + StatusCode::FORBIDDEN + ); + let grants: Vec = + serde_json::from_value(json!(BuiltInProfile::Administrator.selection())).unwrap(); + for seed in [&ids, &foreign] { + sqlx::query!("INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source) + VALUES ($1,$2,$3,1,$4,true,'individual')", Uuid::now_v7(), seed.org_id, seed.user_id, &grants).execute(pool).await.unwrap(); + sqlx::query!( + "UPDATE organizations SET permission_policy_version=1 WHERE id=$1", + seed.org_id + ) + .execute(pool) + .await + .unwrap(); + } + let command = TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 0, + action: TemplateAction::Create { + name: "History readers".into(), + grants: BuiltInProfile::Member.selection().iter().collect(), + }, + }; + templates::execute(pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(); + let stored = sqlx::query!( + "SELECT id, audit FROM permission_change_receipts WHERE org_id=$1 AND request_id=$2", + ids.org_id, + command.request_id + ) + .fetch_one(pool) + .await + .unwrap(); + let body = json!({"receipt_id": stored.id}); + let historical = api.json(name, body.clone(), &cookie).await; + assert_eq!(historical["id"], json!(stored.id)); + assert_eq!( + historical["actor"], + json!({"kind":"user", "user_id":ids.user_id}) + ); + assert_eq!( + historical["audit"], + json!({"kind":"template", "details":stored.audit}) + ); + assert!(historical["created_at"].is_string()); + for private in ["request_id", "intent", "result"] { + assert!(historical.get(private).is_none()); + } + check_historical_shapes(pool, api, &ids, &cookie).await; + check_browsing(pool, api, &ids, &foreign, &cookie).await; + check_auth_lookup_error(pool, api, stored.id, &cookie).await; + assert_eq!(api.json(name, missing, &cookie).await, Value::Null); + let foreign_cookie = api.cookie(foreign.user_id).await; + assert_eq!( + api.json(name, body.clone(), &foreign_cookie).await, + Value::Null + ); + assert_eq!(api.json(name, json!({"receipt_id":stored.id,"org_id":ids.org_id,"user_id":ids.user_id,"is_administrator":true}), &foreign_cookie).await, Value::Null); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator=false WHERE user_id=$1", + ids.user_id + ) + .execute(pool) + .await + .unwrap(); + sqlx::query!("UPDATE users SET org_role='admin' WHERE id=$1", ids.user_id) + .execute(pool) + .await + .unwrap(); + // All ordinary grants and claimed identity cannot replace explicit Administrator status. + let denied = api.call(name, json!({"receipt_id":stored.id,"org_id":foreign.org_id,"user_id":foreign.user_id,"is_administrator":true}), Some(&cookie), false).await; + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert!(!denied.text().await.unwrap().contains("History readers")); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator=true WHERE user_id=$1", + ids.user_id + ) + .execute(pool) + .await + .unwrap(); + sqlx::query!( + "UPDATE permission_change_receipts SET format_version=99 WHERE id=$1", + stored.id + ) + .execute(pool) + .await + .unwrap(); + assert_unavailable(api.call(name, body.clone(), Some(&cookie), false).await).await; + sqlx::query!( + "UPDATE permission_change_receipts SET format_version=1, audit='{}'::jsonb WHERE id=$1", + stored.id + ) + .execute(pool) + .await + .unwrap(); + assert_unavailable(api.call(name, body.clone(), Some(&cookie), false).await).await; + sqlx::query!( + "UPDATE person_permission_states SET grants=ARRAY['secret_invalid_grant'] WHERE user_id=$1", + ids.user_id + ) + .execute(pool) + .await + .unwrap(); + assert_unavailable(api.call(name, body.clone(), Some(&cookie), false).await).await; + assert_unavailable( + api.call( + "list_permission_audit", + json!({"after":null,"expected_requester":null}), + Some(&cookie), + false, + ) + .await, + ) + .await; + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(pool) + .await + .unwrap(); + assert_eq!( + api.call(name, body, Some(&cookie), false).await.status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + api.call( + "list_permission_audit", + json!({"after":null,"expected_requester":null}), + Some(&cookie), + false + ) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + api.client + .post(format!("{}/test/expire", api.base)) + .header("cookie", &foreign_cookie) + .send() + .await + .unwrap(); + assert_eq!( + api.call( + name, + json!({"receipt_id":stored.id}), + Some(&foreign_cookie), + false + ) + .await + .status(), + StatusCode::UNAUTHORIZED + ); + assert_eq!( + api.call( + "list_permission_audit", + json!({"after":null,"expected_requester":null}), + Some(&foreign_cookie), + false + ) + .await + .status(), + StatusCode::UNAUTHORIZED + ); +} + +async fn check_historical_shapes( + pool: &PgPool, + api: &Api, + ids: &crate::server_fns::test_seed::SeedIds, + cookie: &str, +) { + use crate::server_fns::permissions::{profiles, project_management}; + let profile = profiles::ProfileCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 1, + user_id: ids.user_id, + expected_person_revision: 0, + action: profiles::ProfileAction::Edit, + grants: BuiltInProfile::Administrator.selection().iter().collect(), + remove_projects: vec![], + remove_people: vec![], + }; + profiles::execute(pool, ids.org_id, ids.user_id, &profile) + .await + .unwrap(); + let project = project_management::ProjectManagersCommand { + kind: crate::models::project_managers::ProjectManagersCommandKind::ReplaceProjectManagers, + request_id: Uuid::now_v7(), + expected_access_revision: 1, + project_id: ids.project_id, + manager_ids: vec![ids.user_id], + }; + project_management::execute(pool, ids.org_id, ids.user_id, &project) + .await + .unwrap(); + let rows = sqlx::query!( + "SELECT id, audit FROM permission_change_receipts WHERE org_id=$1 ORDER BY id", + ids.org_id + ) + .fetch_all(pool) + .await + .unwrap(); + assert_eq!(rows.len(), 3); + for row in rows { + let value = api + .json("get_permission_audit", json!({"receipt_id":row.id}), cookie) + .await; + assert_eq!(value["audit"]["details"], row.audit); + let typed: crate::models::permission_audit::AuditEntry = + serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(typed).unwrap(), value); + } + let operator_id = Uuid::now_v7(); + sqlx::query!("INSERT INTO permission_change_receipts (id, org_id, operator_id, operator_command, request_id, format_version, intent, result, audit) + SELECT $1, org_id, 'operator-invocation', 'permission.change', $2, format_version, + '{\"private_input\":\"sentinel\"}'::jsonb, '{\"private_result\":\"sentinel\"}'::jsonb, audit + FROM permission_change_receipts WHERE org_id=$3 AND request_id=$4", operator_id, Uuid::now_v7(), ids.org_id, profile.request_id).execute(pool).await.unwrap(); + let value = api + .json( + "get_permission_audit", + json!({"receipt_id":operator_id}), + cookie, + ) + .await; + assert_eq!( + value["actor"], + json!({"kind":"operator", "invocation_id":"operator-invocation", "command":"permission.change"}) + ); + assert!(value["audit"]["details"]["change"].is_null()); + let typed: crate::models::permission_audit::AuditEntry = + serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(typed).unwrap(), value); + for private in ["private_input", "private_result", "sentinel", "request_id"] { + assert!(!value.to_string().contains(private)); + } +} + +async fn check_browsing( + pool: &PgPool, + api: &Api, + ids: &crate::server_fns::test_seed::SeedIds, + foreign: &crate::server_fns::test_seed::SeedIds, + cookie: &str, +) { + use crate::models::permission_audit::AuditPage; + // The existing shape checks created template/profile/project-manager and + // explicit unchanged/operator history. Extend it past one transport page. + for index in 0..24 { + let command = TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 2 + index, + action: TemplateAction::Create { + name: format!("Browsable history {index}"), + grants: BuiltInProfile::Member.selection().iter().collect(), + }, + }; + templates::execute(pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(); + } + sqlx::query!( + "UPDATE permission_change_receipts SET created_at='2026-09-07 12:00:00+00' WHERE org_id=$1", + ids.org_id + ) + .execute(pool) + .await + .unwrap(); + let body = json!({"after":null,"expected_requester":null}); + let name = "list_permission_audit"; + assert_eq!( + api.call(name, body.clone(), None, false).await.status(), + StatusCode::UNAUTHORIZED + ); + let first: AuditPage = + serde_json::from_value(api.json(name, body.clone(), cookie).await).unwrap(); + assert_eq!(first.entries.len(), 25); + assert_eq!( + (first.requester.org_id, first.requester.user_id), + (ids.org_id, ids.user_id) + ); + let next = json!({"after":first.next_after,"expected_requester":first.requester}); + let second: AuditPage = + serde_json::from_value(api.json(name, next.clone(), cookie).await).unwrap(); + assert_eq!(second.entries.len(), 3); + assert!(second.next_after.is_none()); + let stored=sqlx::query_scalar!("SELECT id FROM permission_change_receipts WHERE org_id=$1 ORDER BY created_at DESC,id DESC",ids.org_id).fetch_all(pool).await.unwrap(); + assert_eq!( + first + .entries + .iter() + .chain(&second.entries) + .map(|entry| entry.id) + .collect::>(), + stored + ); + for entry in first.entries.iter().chain(&second.entries) { + let wire = serde_json::to_value(entry).unwrap(); + assert_eq!( + api.json( + "get_permission_audit", + json!({"receipt_id":entry.id}), + cookie + ) + .await, + wire + ); + for secret in [ + "private_input", + "private_result", + "sentinel", + "request_id", + "intent", + ] { + assert!(!wire.to_string().contains(secret)); + } + } + let foreign_cookie = api.cookie(foreign.user_id).await; + assert_eq!( + api.call(name, next.clone(), Some(&foreign_cookie), false) + .await + .status(), + StatusCode::FORBIDDEN + ); + let forged=api.json(name,json!({"after":first.next_after,"expected_requester":null,"org_id":ids.org_id,"user_id":ids.user_id}),&foreign_cookie).await; + assert_eq!(forged["entries"], json!([])); + assert_eq!(forged["requester"]["org_id"], json!(foreign.org_id)); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator=false WHERE user_id=$1", + ids.user_id + ) + .execute(pool) + .await + .unwrap(); + let denied = api.call(name, next, Some(cookie), false).await; + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert!(!denied.text().await.unwrap().contains("Browsable history")); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator=true WHERE user_id=$1", + ids.user_id + ) + .execute(pool) + .await + .unwrap(); + let broken = first.entries[2].id; + sqlx::query!( + "UPDATE permission_change_receipts SET format_version=99 WHERE id=$1", + broken + ) + .execute(pool) + .await + .unwrap(); + assert_unavailable(api.call(name, body.clone(), Some(cookie), false).await).await; + sqlx::query!( + "UPDATE permission_change_receipts SET format_version=1 WHERE id=$1", + broken + ) + .execute(pool) + .await + .unwrap(); + let legacy = crate::server_fns::test_seed::seed(pool, OrgRole::Admin).await; + assert_eq!( + api.call(name, body, Some(&api.cookie(legacy.user_id).await), false) + .await + .status(), + StatusCode::FORBIDDEN + ); +} + +async fn assert_unavailable(response: reqwest::Response) { + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let text = response.text().await.unwrap(); + assert!(text.contains("Permission history is unavailable"), "{text}"); + for private in [ + "History readers", + "secret_invalid_grant", + "permission_change_receipts", + "person_permission_states", + "SELECT", + "sqlx", + "format_version", + ] { + assert!(!text.contains(private), "{text}"); + } +} + +async fn check_auth_lookup_error(pool: &PgPool, api: &Api, receipt: Uuid, cookie: &str) { + for (name, body, expected) in [ + ( + "get_permission_audit", + json!({"receipt_id":receipt}), + "Permission history is unavailable", + ), + ( + "list_permission_audit", + json!({"after":null,"expected_requester":null}), + "Permission history is unavailable", + ), + ( + "get_my_permissions", + json!({}), + "Permission state is unavailable", + ), + ] { + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE users IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *hold) + .await + .unwrap() + .unwrap(); + let cancel = async { + crate::server_fns::test_seed::wait_for_blocked(pool, holder).await; + let cancelled = sqlx::query_scalar!("SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE datname=current_database() AND $1 = ANY(pg_blocking_pids(pid))", holder).fetch_all(pool).await.unwrap(); + assert_eq!(cancelled, vec![Some(true)]); + hold.rollback().await.unwrap(); + }; + let (response, ()) = tokio::join!(api.call(name, body, Some(cookie), false), cancel); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + let text = response.text().await.unwrap(); + assert!(text.contains(expected), "{name}: {text}"); + for private in ["canceling statement", "SELECT", "sqlx", "users"] { + assert!(!text.contains(private), "{name}: {text}"); + } + } + assert_eq!( + api.json( + "get_permission_audit", + json!({"receipt_id":receipt}), + cookie + ) + .await["id"], + json!(receipt) + ); +} diff --git a/crates/horae/src/server_fns/importers/authorization_tests/project_managers.rs b/crates/horae/src/server_fns/importers/authorization_tests/project_managers.rs index c7538890..2e037d9e 100644 --- a/crates/horae/src/server_fns/importers/authorization_tests/project_managers.rs +++ b/crates/horae/src/server_fns/importers/authorization_tests/project_managers.rs @@ -174,6 +174,20 @@ pub(super) async fn check(pool: &PgPool, api: &Api) { api.json("save_project_managers", keep, &cookie).await, json!({"project_id":ids.project_id,"access_revision":1,"changed":false}) ); + let request_id: Uuid = serde_json::from_value(command["request_id"].clone()).unwrap(); + let receipt_id=sqlx::query_scalar!("SELECT id FROM permission_change_receipts WHERE org_id=$1 AND actor_user_id=$2 AND request_id=$3",ids.org_id,ids.user_id,request_id).fetch_one(pool).await.unwrap(); + for (name, body) in [ + ("get_permission_audit", json!({"receipt_id":receipt_id})), + ( + "list_permission_audit", + json!({"after":null,"expected_requester":requester}), + ), + ] { + assert_eq!( + api.call(name, body, Some(&cookie), false).await.status(), + StatusCode::FORBIDDEN + ); + } grants( pool, ids.org_id, diff --git a/crates/horae/src/server_fns/permissions.rs b/crates/horae/src/server_fns/permissions.rs index b6b00e2a..43c4489d 100644 --- a/crates/horae/src/server_fns/permissions.rs +++ b/crates/horae/src/server_fns/permissions.rs @@ -7,6 +7,7 @@ use uuid::Uuid; use crate::models::permissions::{PermissionSource, PermissionTemplate, PersonPermissions}; +pub(crate) mod audit; pub(crate) mod own; pub(crate) mod preflight; pub(crate) mod profiles; @@ -172,6 +173,10 @@ mod profile_tests; #[path = "permissions/tests/preflight.rs"] mod preflight_tests; +#[cfg(test)] +#[path = "permissions/tests/audit.rs"] +mod audit_tests; + #[cfg(test)] #[path = "permissions/tests/project_management.rs"] mod project_management_tests; diff --git a/crates/horae/src/server_fns/permissions/audit.rs b/crates/horae/src/server_fns/permissions/audit.rs new file mode 100644 index 00000000..c2f47700 --- /dev/null +++ b/crates/horae/src/server_fns/permissions/audit.rs @@ -0,0 +1,282 @@ +//! Administrator-only historical projection, never a source of current authority. + +use chrono::{DateTime, Utc}; +use horae_core::permissions::catalog::{Permission, PermissionSelection}; +use sqlx::{PgPool, Postgres, Transaction}; +use uuid::Uuid; + +use super::{PermissionStorageError, load_person_permissions}; +use crate::models::permission_audit::{ + AuditCursor, AuditEntry, AuditPage, HistoricalSource, PersonSnapshot, +}; +pub(super) use crate::models::permission_audit::{AuditPrincipal, HistoricalAudit}; +use crate::models::permission_editor::PermissionRequester; + +pub(crate) async fn page( + pool: &PgPool, + org_id: Uuid, + requester_id: Uuid, + after: Option<&AuditCursor>, + expected_requester: Option, +) -> Result { + let requester = PermissionRequester { + org_id, + user_id: requester_id, + }; + if expected_requester.is_some_and(|expected| expected != requester) { + return Err(AuditReadError::Forbidden); + } + let mut tx = begin_read(pool, org_id, requester_id).await?; + let mut rows = sqlx::query_as!( + StoredAudit, + r#"SELECT id,actor_user_id,operator_id,operator_command,format_version,audit, + created_at AS "created_at: DateTime" + FROM permission_change_receipts + WHERE org_id=$1 + AND ($2::timestamptz IS NULL OR (created_at,id)<($2,$3)) + ORDER BY created_at DESC,id DESC LIMIT 26"#, + org_id, + after.map(|cursor| cursor.created_at) as Option>, + after.map(|cursor| cursor.id) + ) + .fetch_all(&mut *tx) + .await?; + let has_more = rows.len() > 25; + rows.truncate(25); + let entries = rows + .into_iter() + .map(StoredAudit::project) + .collect::, _>>()?; + let next_after = if has_more { + entries.last().map(|entry| AuditCursor { + created_at: entry.created_at, + id: entry.id, + }) + } else { + None + }; + tx.commit().await?; + Ok(AuditPage { + requester, + entries, + next_after, + }) +} + +struct StoredAudit { + id: Uuid, + actor_user_id: Option, + operator_id: Option, + operator_command: Option, + format_version: i32, + audit: serde_json::Value, + created_at: DateTime, +} + +impl StoredAudit { + fn project(self) -> Result { + let actor = match (self.actor_user_id, self.operator_id, self.operator_command) { + (Some(user_id), None, None) => AuditPrincipal::User { user_id }, + (None, Some(invocation_id), Some(command)) + if !invocation_id.trim().is_empty() && !command.trim().is_empty() => + { + AuditPrincipal::Operator { + invocation_id, + command, + } + } + _ => return Err(AuditReadError::InvalidDocument), + }; + Ok(AuditEntry { + id: self.id, + actor, + created_at: self.created_at, + audit: decode(self.format_version, self.audit)?, + }) + } +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum AuditReadError { + #[error("Current administrator authority is required")] + Forbidden, + #[error("Unsupported audit format")] + UnsupportedFormat, + #[error("Invalid historical permission audit")] + InvalidDocument, + #[error(transparent)] + Storage(#[from] PermissionStorageError), + #[error(transparent)] + Database(#[from] sqlx::Error), +} + +fn revision_step(before: i64, after: i64, changed: bool) -> bool { + before >= 0 && before.checked_add(i64::from(changed)) == Some(after) +} + +fn valid_grants(version: i32, grants: &[Permission]) -> bool { + u32::try_from(version) + .is_ok_and(|version| PermissionSelection::from_stored(version, grants).is_ok()) +} + +impl PersonSnapshot { + fn valid(&self, version: u32) -> bool { + let source_valid = match self.source { + HistoricalSource::Template { + applied_revision, .. + } => applied_revision >= 0, + _ => true, + }; + self.revision >= 0 + && source_valid + && PermissionSelection::from_stored(version, &self.grants).is_ok() + } +} + +impl HistoricalAudit { + fn valid(&self) -> bool { + match self { + Self::Template(audit) => { + revision_step(audit.previous_access_revision, audit.access_revision, true) + && matches!( + (&audit.before, &audit.after), + (Some(_), None) | (None, Some(_)) + ) + && audit.before.iter().chain(&audit.after).all(|snapshot| { + snapshot.revision >= 0 + && valid_grants(snapshot.catalog_version, &snapshot.grants) + }) + && audit.detached_people.iter().all(|person| { + audit + .before + .as_ref() + .is_some_and(|template| template.id == person.previous_template_id) + && person.previous_applied_revision >= 0 + && revision_step(person.previous_revision, person.revision, true) + && valid_grants(person.catalog_version, &person.grants) + }) + } + Self::Profile(audit) => { + revision_step( + audit.previous_access_revision, + audit.access_revision, + audit.change.is_some(), + ) && audit.change.as_ref().is_none_or(|change| { + change.before.valid(change.catalog_version) + && change.after.valid(change.catalog_version) + && revision_step(change.before.revision, change.after.revision, true) + && change + .removed_projects + .iter() + .chain(&change.removed_people) + .all(|link| link.revision >= 0) + }) + } + Self::ProjectManagers(audit) => { + revision_step( + audit.previous_access_revision, + audit.access_revision, + audit.change.is_some(), + ) && audit.change.as_ref().is_none_or(|change| { + (!change.added.is_empty() || !change.removed.is_empty()) + && change + .added + .iter() + .chain(&change.removed) + .all(|link| link.revision >= 0) + }) + } + } + } +} + +pub(super) fn decode( + version: i32, + document: serde_json::Value, +) -> Result { + if version != 1 { + return Err(AuditReadError::UnsupportedFormat); + } + let decoded = if document.get("user_id").is_some() { + serde_json::from_value(document).map(HistoricalAudit::Profile) + } else if document.get("project_id").is_some() { + serde_json::from_value(document).map(HistoricalAudit::ProjectManagers) + } else { + serde_json::from_value(document).map(HistoricalAudit::Template) + } + .map_err(|_| AuditReadError::InvalidDocument)?; + if !decoded.valid() { + return Err(AuditReadError::InvalidDocument); + } + Ok(decoded) +} + +/// Hold the same current authority through either historical projection. +async fn begin_read( + pool: &PgPool, + org_id: Uuid, + requester: Uuid, +) -> Result, AuditReadError> { + let mut tx = pool.begin().await?; + sqlx::query!("SET TRANSACTION ISOLATION LEVEL READ COMMITTED, READ WRITE") + .execute(&mut *tx) + .await?; + sqlx::query!( + "SELECT set_config(name, + (CASE WHEN setting::bigint = 0 THEN limits.milliseconds + ELSE LEAST(setting::bigint, limits.milliseconds) END)::text, true) + FROM pg_settings + JOIN (VALUES ('statement_timeout', 5000::bigint), + ('idle_in_transaction_session_timeout', 10000::bigint)) + AS limits(setting_name, milliseconds) ON name = limits.setting_name" + ) + .fetch_all(&mut *tx) + .await?; + let policy = sqlx::query_scalar!( + "SELECT permission_policy_version FROM organizations WHERE id = $1 FOR SHARE", + org_id + ) + .fetch_optional(&mut *tx) + .await?; + let active = sqlx::query_scalar!( + "SELECT id FROM users WHERE org_id = $1 AND id = $2 AND active FOR SHARE", + org_id, + requester + ) + .fetch_optional(&mut *tx) + .await?; + if policy != Some(1) || active.is_none() { + return Err(AuditReadError::Forbidden); + } + if !load_person_permissions(&mut tx, org_id, requester) + .await? + .is_some_and(|state| state.is_administrator) + { + return Err(AuditReadError::Forbidden); + } + Ok(tx) +} + +/// Reads one historical record using authenticated requester/tenant IDs. +/// No receipt lookup or snapshot decoding occurs before current authorization. +pub(crate) async fn read( + pool: &PgPool, + org_id: Uuid, + requester: Uuid, + receipt_id: Uuid, +) -> Result, AuditReadError> { + let mut tx = begin_read(pool, org_id, requester).await?; + let row = sqlx::query_as!( + StoredAudit, + "SELECT id, actor_user_id, operator_id, operator_command, format_version, audit, + created_at as \"created_at: DateTime\" + FROM permission_change_receipts WHERE org_id = $1 AND id = $2", + org_id, + receipt_id + ) + .fetch_optional(&mut *tx) + .await?; + let record = row.map(StoredAudit::project).transpose()?; + tx.commit().await?; + Ok(record) +} diff --git a/crates/horae/src/server_fns/permissions/tests/audit.rs b/crates/horae/src/server_fns/permissions/tests/audit.rs new file mode 100644 index 00000000..940cde02 --- /dev/null +++ b/crates/horae/src/server_fns/permissions/tests/audit.rs @@ -0,0 +1,1064 @@ +use super::audit::{AuditPrincipal, AuditReadError, decode, read}; +use super::templates::{TemplateAction, TemplateCommand}; +use super::*; +use crate::server_fns::test_seed::{SeedIds, seed, wait_for_blocked}; +use horae_core::permissions::catalog::BuiltInProfile; +use horae_core::types::OrgRole; +use serde_json::json; +use sqlx::PgPool; +use std::time::Duration; + +#[path = "audit/browsing.rs"] +mod browsing; + +async fn fixture(pool: &PgPool, administrator: bool) -> SeedIds { + let ids = seed(pool, OrgRole::Admin).await; + let grants: Vec = serde_json::from_value( + serde_json::to_value(BuiltInProfile::Administrator.selection()).unwrap(), + ) + .unwrap(); + sqlx::query!( + "UPDATE organizations SET permission_policy_version = 1 WHERE id = $1", + ids.org_id + ) + .execute(pool) + .await + .unwrap(); + sqlx::query!("INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source) + VALUES ($1, $2, $3, 1, $4, $5, 'individual')", + Uuid::now_v7(), ids.org_id, ids.user_id, &grants, administrator).execute(pool).await.unwrap(); + ids +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn direct_deactivation_winning_user_lock_denies_audit_read(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let mut revoke = pool.begin().await.unwrap(); + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&mut *revoke) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *revoke) + .await + .unwrap() + .unwrap(); + let reader_pool = pool.clone(); + let reader = + tokio::spawn(async move { read(&reader_pool, ids.org_id, ids.user_id, receipt).await }); + wait_for_blocked(&pool, pid).await; + revoke.commit().await.unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), reader) + .await + .unwrap() + .unwrap(), + Err(AuditReadError::Forbidden) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn audit_reader_overrides_transaction_defaults_without_changing_connection(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let reader = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query!("SET default_transaction_isolation = 'repeatable read'") + .execute(&mut *connection) + .await?; + sqlx::query!("SET default_transaction_read_only = on") + .execute(&mut *connection) + .await?; + Ok(()) + }) + }) + .connect_with(pool.connect_options().as_ref().clone()) + .await + .unwrap(); + assert_eq!( + read(&reader, ids.org_id, ids.user_id, receipt) + .await + .unwrap() + .unwrap() + .id, + receipt + ); + let defaults = sqlx::query!("SELECT current_setting('default_transaction_isolation') AS isolation, current_setting('default_transaction_read_only') AS readonly") + .fetch_one(&reader).await.unwrap(); + assert_eq!(defaults.isolation.as_deref(), Some("repeatable read")); + assert_eq!(defaults.readonly.as_deref(), Some("on")); + reader.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn audit_reader_holds_requester_until_materialization_finishes(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *hold) + .await + .unwrap() + .unwrap(); + let reader_pool = pool.clone(); + let reader = + tokio::spawn(async move { read(&reader_pool, ids.org_id, ids.user_id, receipt).await }); + wait_for_blocked(&pool, holder).await; + let reader_pid = sqlx::query_scalar!("SELECT pid FROM pg_stat_activity WHERE datname = current_database() AND $1 = ANY(pg_blocking_pids(pid))", holder).fetch_one(&pool).await.unwrap().unwrap(); + let writer_pool = pool.clone(); + let writer = tokio::spawn(async move { + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&writer_pool) + .await + .unwrap(); + }); + wait_for_blocked(&pool, reader_pid).await; + hold.commit().await.unwrap(); + assert_eq!( + tokio::time::timeout(Duration::from_secs(5), reader) + .await + .unwrap() + .unwrap() + .unwrap() + .unwrap() + .id, + receipt + ); + tokio::time::timeout(Duration::from_secs(5), writer) + .await + .unwrap() + .unwrap(); + assert!(matches!( + read(&pool, ids.org_id, ids.user_id, receipt).await, + Err(AuditReadError::Forbidden) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn audit_timeout_preserves_stricter_limits_and_releases_locks(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let reader = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query!("SET statement_timeout = '250ms'") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect_with(pool.connect_options().as_ref().clone()) + .await + .unwrap(); + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let error = tokio::time::timeout( + Duration::from_secs(2), + read(&reader, ids.org_id, ids.user_id, receipt), + ) + .await + .unwrap() + .unwrap_err(); + let AuditReadError::Database(sqlx::Error::Database(error)) = error else { + panic!("Expected statement timeout: {error}") + }; + assert_eq!(error.code().as_deref(), Some("57014")); + hold.rollback().await.unwrap(); + assert_eq!( + sqlx::query_scalar!("SELECT current_setting('statement_timeout')") + .fetch_one(&reader) + .await + .unwrap() + .as_deref(), + Some("250ms") + ); + assert_unlocked(&pool, &ids).await; + assert_eq!( + read(&reader, ids.org_id, ids.user_id, receipt) + .await + .unwrap() + .unwrap() + .id, + receipt + ); + reader.close().await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn cancelled_audit_read_releases_both_gates_and_single_connection(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let reader_pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect_with(pool.connect_options().as_ref().clone()) + .await + .unwrap(); + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *hold) + .await + .unwrap() + .unwrap(); + let connection = reader_pool.clone(); + let reader = + tokio::spawn(async move { read(&connection, ids.org_id, ids.user_id, receipt).await }); + wait_for_blocked(&pool, holder).await; + reader.abort(); + assert!(reader.await.unwrap_err().is_cancelled()); + hold.rollback().await.unwrap(); + tokio::time::timeout( + Duration::from_secs(5), + sqlx::query!("SELECT 1 AS drained").fetch_one(&reader_pool), + ) + .await + .unwrap() + .unwrap(); + assert_unlocked(&pool, &ids).await; + assert_eq!( + read(&reader_pool, ids.org_id, ids.user_id, receipt) + .await + .unwrap() + .unwrap() + .id, + receipt + ); + reader_pool.close().await; +} + +async fn assert_unlocked(pool: &PgPool, ids: &SeedIds) { + let mut tx = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id=$1 FOR UPDATE NOWAIT", + ids.org_id + ) + .fetch_one(&mut *tx) + .await + .unwrap(); + sqlx::query!( + "SELECT id FROM users WHERE id=$1 FOR UPDATE NOWAIT", + ids.user_id + ) + .fetch_one(&mut *tx) + .await + .unwrap(); + tx.rollback().await.unwrap(); +} + +async fn receipt_id(pool: &PgPool, request: Uuid) -> Uuid { + sqlx::query_scalar!( + "SELECT id FROM permission_change_receipts WHERE request_id = $1", + request + ) + .fetch_one(pool) + .await + .unwrap() +} + +async fn person(pool: &PgPool, org: Uuid, administrator: bool, profile: BuiltInProfile) -> Uuid { + let user = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, 'Audit person')", + user, + org, + format!("{user}@test.com") + ) + .execute(pool) + .await + .unwrap(); + let grants: Vec = + serde_json::from_value(serde_json::to_value(profile.selection()).unwrap()).unwrap(); + sqlx::query!("INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source) + VALUES ($1, $2, $3, 1, $4, $5, 'individual')", + Uuid::now_v7(), org, user, &grants, administrator).execute(pool).await.unwrap(); + user +} + +async fn create_template(pool: &PgPool, ids: &SeedIds) -> (Uuid, Uuid) { + let request = TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 0, + action: TemplateAction::Create { + name: "Readers".into(), + grants: BuiltInProfile::Member.selection().iter().collect(), + }, + }; + let outcome = super::templates::execute(pool, ids.org_id, ids.user_id, &request) + .await + .unwrap(); + ( + receipt_id(pool, request.request_id).await, + outcome.template_id, + ) +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn administrator_reads_historical_template_without_intent_or_replay_result(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, template) = create_template(&pool, &ids).await; + let record = read(&pool, ids.org_id, ids.user_id, receipt) + .await + .unwrap() + .unwrap(); + assert_eq!(record.id, receipt); + assert_eq!( + record.actor, + AuditPrincipal::User { + user_id: ids.user_id + } + ); + let view = serde_json::to_value(&record).unwrap(); + assert!(view.get("intent").is_none()); + assert!(view.get("result").is_none()); + assert!(view.get("request_id").is_none()); + assert_eq!(view["audit"]["kind"], "template"); + assert_eq!( + view["audit"]["details"]["after"]["id"], + template.to_string() + ); + let deletion = TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 1, + action: TemplateAction::Delete { + id: template, + expected_revision: 0, + }, + }; + super::templates::execute(&pool, ids.org_id, ids.user_id, &deletion) + .await + .unwrap(); + let historical = read(&pool, ids.org_id, ids.user_id, receipt) + .await + .unwrap() + .unwrap(); + assert_eq!(serde_json::to_value(historical).unwrap(), view); + let removed = read( + &pool, + ids.org_id, + ids.user_id, + receipt_id(&pool, deletion.request_id).await, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(removed).unwrap()["audit"]["details"]["before"]["id"], + template.to_string() + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn legacy_admin_and_all_grants_without_explicit_identity_cannot_read_audit(pool: PgPool) { + let ids = fixture(&pool, false).await; + assert!(matches!( + read(&pool, ids.org_id, ids.user_id, Uuid::now_v7()).await, + Err(AuditReadError::Forbidden) + )); +} + +#[test] +fn historical_decoder_distinguishes_missing_change_from_explicit_noop() { + let mut audit = + json!({"project_id": Uuid::now_v7(), "previous_access_revision": 4, "access_revision": 4}); + assert!(matches!( + decode(1, audit.clone()), + Err(AuditReadError::InvalidDocument) + )); + audit["change"] = serde_json::Value::Null; + assert!(decode(1, audit.clone()).is_ok()); + audit["access_revision"] = json!(5); + assert!(matches!( + decode(1, audit), + Err(AuditReadError::InvalidDocument) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn profile_and_project_history_decode_exact_changes_and_noops(pool: PgPool) { + use super::profiles::{ProfileAction, ProfileCommand}; + use super::project_management::ProjectManagersCommand; + let ids = fixture(&pool, true).await; + let mut profile = ProfileCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 0, + user_id: ids.user_id, + expected_person_revision: 0, + action: ProfileAction::BuiltIn { + profile: BuiltInProfile::Administrator, + }, + grants: BuiltInProfile::Administrator.selection().iter().collect(), + remove_projects: vec![], + remove_people: vec![], + }; + super::profiles::execute(&pool, ids.org_id, ids.user_id, &profile) + .await + .unwrap(); + let record = read( + &pool, + ids.org_id, + ids.user_id, + receipt_id(&pool, profile.request_id).await, + ) + .await + .unwrap() + .unwrap(); + let audit = serde_json::to_value(record).unwrap(); + assert_eq!(audit["audit"]["kind"], "profile"); + assert_eq!( + audit["audit"]["details"]["change"]["after"]["grants"], + serde_json::to_value(&profile.grants).unwrap() + ); + profile.request_id = Uuid::now_v7(); + profile.expected_access_revision = 1; + profile.expected_person_revision = 1; + profile.action = ProfileAction::Edit; + super::profiles::execute(&pool, ids.org_id, ids.user_id, &profile) + .await + .unwrap(); + let noop = read( + &pool, + ids.org_id, + ids.user_id, + receipt_id(&pool, profile.request_id).await, + ) + .await + .unwrap() + .unwrap(); + assert!(serde_json::to_value(noop).unwrap()["audit"]["details"]["change"].is_null()); + let mut managers = ProjectManagersCommand { + kind: crate::models::project_managers::ProjectManagersCommandKind::ReplaceProjectManagers, + request_id: Uuid::now_v7(), + expected_access_revision: 1, + project_id: ids.project_id, + manager_ids: vec![ids.user_id], + }; + super::project_management::execute(&pool, ids.org_id, ids.user_id, &managers) + .await + .unwrap(); + let added_id = receipt_id(&pool, managers.request_id).await; + let added = serde_json::to_value( + read(&pool, ids.org_id, ids.user_id, added_id) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(added["audit"]["kind"], "project_managers"); + assert_eq!( + added["audit"]["details"]["change"]["added"][0]["manager_id"], + ids.user_id.to_string() + ); + managers.request_id = Uuid::now_v7(); + managers.expected_access_revision = 2; + super::project_management::execute(&pool, ids.org_id, ids.user_id, &managers) + .await + .unwrap(); + let noop = read( + &pool, + ids.org_id, + ids.user_id, + receipt_id(&pool, managers.request_id).await, + ) + .await + .unwrap() + .unwrap(); + assert!(serde_json::to_value(noop).unwrap()["audit"]["details"]["change"].is_null()); + managers.request_id = Uuid::now_v7(); + managers.manager_ids.clear(); + super::project_management::execute(&pool, ids.org_id, ids.user_id, &managers) + .await + .unwrap(); + let removed = read( + &pool, + ids.org_id, + ids.user_id, + receipt_id(&pool, managers.request_id).await, + ) + .await + .unwrap() + .unwrap(); + assert_eq!( + serde_json::to_value(removed).unwrap()["audit"]["details"]["change"]["removed"][0], + added["audit"]["details"]["change"]["added"][0] + ); + assert_eq!( + serde_json::to_value( + read(&pool, ids.org_id, ids.user_id, added_id) + .await + .unwrap() + .unwrap() + ) + .unwrap(), + added + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn tenant_activity_and_policy_checks_precede_receipt_existence_and_decode(pool: PgPool) { + let ids = fixture(&pool, true).await; + let foreign = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + assert!( + read(&pool, foreign.org_id, foreign.user_id, receipt) + .await + .unwrap() + .is_none() + ); + assert!( + read(&pool, foreign.org_id, foreign.user_id, Uuid::now_v7()) + .await + .unwrap() + .is_none() + ); + for actor in [foreign.user_id, Uuid::now_v7()] { + assert!(matches!( + read(&pool, ids.org_id, actor, receipt).await, + Err(AuditReadError::Forbidden) + )); + } + sqlx::query!( + "UPDATE permission_change_receipts SET format_version = 99 WHERE id = $1", + receipt + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query!("UPDATE users SET active = false WHERE id = $1", ids.user_id) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + read(&pool, ids.org_id, ids.user_id, receipt).await, + Err(AuditReadError::Forbidden) + )); + sqlx::query!("UPDATE users SET active = true WHERE id = $1", ids.user_id) + .execute(&pool) + .await + .unwrap(); + for mode in [0, 2] { + sqlx::query!( + "UPDATE organizations SET permission_policy_version = $2 WHERE id = $1", + ids.org_id, + mode + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + read(&pool, ids.org_id, ids.user_id, receipt).await, + Err(AuditReadError::Forbidden) + )); + } + sqlx::query!( + "UPDATE organizations SET permission_policy_version = 1 WHERE id = $1", + ids.org_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + read(&pool, ids.org_id, ids.user_id, receipt).await, + Err(AuditReadError::UnsupportedFormat) + )); + sqlx::query!( + "UPDATE permission_change_receipts SET format_version = 1, audit = '{}'::jsonb WHERE id = $1", + receipt + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + read(&pool, ids.org_id, ids.user_id, receipt).await, + Err(AuditReadError::InvalidDocument) + )); + sqlx::query!( + "UPDATE person_permission_states SET grants = ARRAY['not_a_permission'] WHERE user_id = $1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + read(&pool, ids.org_id, ids.user_id, receipt).await, + Err(AuditReadError::Storage(_)) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn operator_attribution_is_not_a_user_and_excludes_private_replay_payload(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let operator_receipt = Uuid::now_v7(); + sqlx::query!("INSERT INTO permission_change_receipts + (id, org_id, operator_id, operator_command, request_id, format_version, intent, result, audit) + SELECT $1, org_id, 'operator-invocation', 'permission.change', $2, format_version, + '{\"private_input\":\"sentinel\"}'::jsonb, '{\"private_result\":\"sentinel\"}'::jsonb, audit + FROM permission_change_receipts WHERE id = $3", operator_receipt, Uuid::now_v7(), receipt) + .execute(&pool).await.unwrap(); + let record = read(&pool, ids.org_id, ids.user_id, operator_receipt) + .await + .unwrap() + .unwrap(); + assert_eq!( + record.actor, + AuditPrincipal::Operator { + invocation_id: "operator-invocation".into(), + command: "permission.change".into() + } + ); + let json = serde_json::to_string(&record).unwrap(); + assert!(!json.contains("sentinel")); + assert!(!json.contains("private_input")); + assert!(!json.contains("private_result")); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn failed_mutation_has_no_success_audit_and_reads_do_not_write(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let request = TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 1, + action: TemplateAction::Create { + name: "READERS".into(), + grants: BuiltInProfile::Member.selection().iter().collect(), + }, + }; + assert!( + super::templates::execute(&pool, ids.org_id, ids.user_id, &request) + .await + .is_err() + ); + read(&pool, ids.org_id, ids.user_id, receipt) + .await + .unwrap() + .unwrap(); + read(&pool, ids.org_id, ids.user_id, Uuid::now_v7()) + .await + .unwrap(); + let count = sqlx::query_scalar!( + "SELECT count(*) FROM permission_change_receipts WHERE org_id = $1", + ids.org_id + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(count, Some(1)); + let revision = sqlx::query_scalar!( + "SELECT access_revision FROM organizations WHERE id = $1", + ids.org_id + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(revision, 1); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn revocation_winning_gate_denies_waiting_historical_reader(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let mut revoke = pool.begin().await.unwrap(); + sqlx::query!( + "SELECT id FROM organizations WHERE id = $1 FOR UPDATE", + ids.org_id + ) + .fetch_one(&mut *revoke) + .await + .unwrap(); + let pid = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *revoke) + .await + .unwrap() + .unwrap(); + let org = ids.org_id; + let actor = ids.user_id; + let worker_pool = pool.clone(); + let pending = tokio::spawn(async move { read(&worker_pool, org, actor, receipt).await }); + wait_for_blocked(&pool, pid).await; + sqlx::query!( + "UPDATE person_permission_states SET is_administrator = false WHERE user_id = $1", + actor + ) + .execute(&mut *revoke) + .await + .unwrap(); + revoke.commit().await.unwrap(); + assert!(matches!( + tokio::time::timeout(Duration::from_secs(5), pending) + .await + .unwrap() + .unwrap(), + Err(AuditReadError::Forbidden) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn reader_winning_gate_finishes_before_revocation_and_later_reads_fail(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + // Pause the real reader after authorization, without adding production hooks. + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid()") + .fetch_one(&mut *hold) + .await + .unwrap() + .unwrap(); + let org = ids.org_id; + let actor = ids.user_id; + let reader_pool = pool.clone(); + let reader = tokio::spawn(async move { read(&reader_pool, org, actor, receipt).await }); + wait_for_blocked(&pool, holder).await; + let reader_pid = sqlx::query_scalar!( + "SELECT pid FROM pg_stat_activity WHERE datname = current_database() + AND $1 = ANY(pg_blocking_pids(pid))", + holder + ) + .fetch_one(&pool) + .await + .unwrap(); + let writer_pool = pool.clone(); + let writer = tokio::spawn(async move { + let mut tx = writer_pool.begin().await.unwrap(); + sqlx::query!("SELECT id FROM organizations WHERE id = $1 FOR UPDATE", org) + .fetch_one(&mut *tx) + .await + .unwrap(); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator = false WHERE user_id = $1", + actor + ) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + }); + wait_for_blocked(&pool, reader_pid.unwrap()).await; + hold.commit().await.unwrap(); + let record = tokio::time::timeout(Duration::from_secs(5), reader) + .await + .unwrap() + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(record.id, receipt); + tokio::time::timeout(Duration::from_secs(5), writer) + .await + .unwrap() + .unwrap(); + assert!(matches!( + read(&pool, org, actor, receipt).await, + Err(AuditReadError::Forbidden) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn current_administrator_reads_inactive_authors_history_without_live_state(pool: PgPool) { + let ids = fixture(&pool, true).await; + let (receipt, _) = create_template(&pool, &ids).await; + let admin = person(&pool, ids.org_id, true, BuiltInProfile::Administrator).await; + let original = serde_json::to_value( + read(&pool, ids.org_id, admin, receipt) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + sqlx::query!("UPDATE users SET active = false WHERE id = $1", ids.user_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query!( + "DELETE FROM person_permission_states WHERE user_id = $1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + let historical = read(&pool, ids.org_id, admin, receipt) + .await + .unwrap() + .unwrap(); + assert_eq!(serde_json::to_value(historical).unwrap(), original); + sqlx::query!( + "DELETE FROM person_permission_states WHERE user_id = $1", + admin + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + read(&pool, ids.org_id, admin, receipt).await, + Err(AuditReadError::Forbidden) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn template_detachment_history_preserves_exact_grants_and_provenance(pool: PgPool) { + use super::profiles::{ProfileAction, ProfileCommand}; + let ids = fixture(&pool, true).await; + let user = person(&pool, ids.org_id, false, BuiltInProfile::Member).await; + let (_, template) = create_template(&pool, &ids).await; + let application = ProfileCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 1, + user_id: user, + expected_person_revision: 0, + action: ProfileAction::Template { + id: template, + expected_revision: 0, + }, + grants: BuiltInProfile::ProjectManager.selection().iter().collect(), + remove_projects: vec![], + remove_people: vec![], + }; + super::profiles::execute(&pool, ids.org_id, ids.user_id, &application) + .await + .unwrap(); + let deletion = TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 2, + action: TemplateAction::Delete { + id: template, + expected_revision: 0, + }, + }; + super::templates::execute(&pool, ids.org_id, ids.user_id, &deletion) + .await + .unwrap(); + let stored = sqlx::query!( + "SELECT id, audit FROM permission_change_receipts WHERE request_id = $1", + deletion.request_id + ) + .fetch_one(&pool) + .await + .unwrap(); + let historical = serde_json::to_value( + read(&pool, ids.org_id, ids.user_id, stored.id) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(historical["audit"]["details"], stored.audit); + let detached = &historical["audit"]["details"]["detached_people"][0]; + assert_eq!(detached["user_id"], json!(user)); + assert_eq!(detached["grants"], json!(application.grants)); + assert_eq!(detached["previous_template_id"], json!(template)); + assert_eq!(detached["previous_revision"], 1); + assert_eq!(detached["revision"], 2); + let application_history = serde_json::to_value( + read( + &pool, + ids.org_id, + ids.user_id, + receipt_id(&pool, application.request_id).await, + ) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!( + application_history["audit"]["details"]["change"]["after"]["source"], + json!({"kind": "template", "value": {"id": template, "applied_revision": 0}}) + ); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn profile_history_keeps_removed_relationships_after_grants_change_again(pool: PgPool) { + use super::profiles::{ProfileAction, ProfileCommand}; + let ids = fixture(&pool, true).await; + let manager = person(&pool, ids.org_id, false, BuiltInProfile::ProjectManager).await; + let project_link = Uuid::now_v7(); + let person_link = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO project_management_assignments (id, org_id, manager_id, project_id) + VALUES ($1, $2, $3, $4)", + project_link, + ids.org_id, + manager, + ids.project_id + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query!( + "INSERT INTO person_management_assignments (id, org_id, manager_id, managed_user_id) + VALUES ($1, $2, $3, $4)", + person_link, + ids.org_id, + manager, + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + let mut command = ProfileCommand { + request_id: Uuid::now_v7(), + expected_access_revision: 0, + user_id: manager, + expected_person_revision: 0, + action: ProfileAction::BuiltIn { + profile: BuiltInProfile::Member, + }, + grants: BuiltInProfile::Member.selection().iter().collect(), + remove_projects: vec![project_link], + remove_people: vec![person_link], + }; + super::profiles::execute(&pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(); + let stored = sqlx::query!( + "SELECT id, audit FROM permission_change_receipts WHERE request_id = $1", + command.request_id + ) + .fetch_one(&pool) + .await + .unwrap(); + command.request_id = Uuid::now_v7(); + command.expected_access_revision = 1; + command.expected_person_revision = 1; + command.action = ProfileAction::BuiltIn { + profile: BuiltInProfile::ProjectManager, + }; + command.grants = BuiltInProfile::ProjectManager.selection().iter().collect(); + command.remove_projects.clear(); + command.remove_people.clear(); + super::profiles::execute(&pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(); + let historical = serde_json::to_value( + read(&pool, ids.org_id, ids.user_id, stored.id) + .await + .unwrap() + .unwrap(), + ) + .unwrap(); + assert_eq!(historical["audit"]["details"], stored.audit); + let change = &historical["audit"]["details"]["change"]; + assert_eq!( + change["removed_projects"], + json!([{"id": project_link, "subject_id": ids.project_id, "revision": 0}]) + ); + assert_eq!( + change["removed_people"], + json!([{"id": person_link, "subject_id": ids.user_id, "revision": 0}]) + ); + assert_eq!( + change["after"]["grants"], + json!(BuiltInProfile::Member.selection()) + ); +} + +#[test] +fn historical_decoder_rejects_unknown_mixed_fields_and_invalid_revision_steps() { + let base = json!({"project_id": Uuid::now_v7(), "previous_access_revision": 0, "access_revision": 0, "change": null}); + assert!(matches!( + decode(2, base.clone()), + Err(AuditReadError::UnsupportedFormat) + )); + for (field, value) in [ + ("extra", json!(true)), + ("user_id", json!(Uuid::now_v7())), + ("previous_access_revision", json!(-1)), + ("access_revision", json!(1)), + ("change", json!({"added": [], "removed": []})), + ] { + let mut bad = base.clone(); + bad[field] = value; + assert!( + matches!(decode(1, bad), Err(AuditReadError::InvalidDocument)), + "{field}" + ); + } +} + +#[test] +fn historical_decoder_rejects_missing_template_snapshots_and_invalid_grants() { + let snapshot = json!({"id": Uuid::now_v7(), "name": "Readers", "catalog_version": 1, + "grants": BuiltInProfile::Member.selection(), "revision": 0}); + let base = json!({"previous_access_revision": 0, "access_revision": 1, "before": null, + "after": snapshot, "detached_people": []}); + assert!(decode(1, base.clone()).is_ok()); + for field in ["before", "after"] { + let mut missing = base.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(matches!( + decode(1, missing), + Err(AuditReadError::InvalidDocument) + )); + } + for (field, value) in [ + ("catalog_version", json!(99)), + ("revision", json!(-1)), + ("grants", json!([])), + ("grants", json!(["unknown_permission"])), + ("grants", json!(["project_write_all"])), + ] { + let mut bad = base.clone(); + bad["after"][field] = value; + assert!( + matches!(decode(1, bad), Err(AuditReadError::InvalidDocument)), + "{field}" + ); + } +} + +#[test] +fn historical_person_snapshot_validates_provenance_without_inferring_admin_identity() { + let person = json!({"grants": BuiltInProfile::Member.selection(), "is_administrator": true, + "source": {"kind": "individual"}, "revision": 0}); + let mut after = person.clone(); + after["revision"] = json!(1); + let base = json!({"user_id": Uuid::now_v7(), "previous_access_revision": 0, "access_revision": 1, + "change": {"catalog_version": 1, "before": person, "after": after, + "removed_projects": [], "removed_people": []}}); + assert!(decode(1, base.clone()).is_ok()); + for source in [ + json!({"kind": "unknown"}), + json!({"kind": "template", "value": {"id": Uuid::now_v7(), "applied_revision": -1}}), + json!({"kind": "individual", "extra": "private"}), + ] { + let mut bad = base.clone(); + bad["change"]["after"]["source"] = source; + assert!(matches!( + decode(1, bad), + Err(AuditReadError::InvalidDocument) + )); + } +} diff --git a/crates/horae/src/server_fns/permissions/tests/audit/browsing.rs b/crates/horae/src/server_fns/permissions/tests/audit/browsing.rs new file mode 100644 index 00000000..f6d67e7e --- /dev/null +++ b/crates/horae/src/server_fns/permissions/tests/audit/browsing.rs @@ -0,0 +1,366 @@ +use super::*; +use crate::models::permission_audit::AuditCursor; +use crate::models::permission_editor::PermissionRequester; +use crate::server_fns::permissions::audit::page; + +async fn receipts(pool: &PgPool, ids: &SeedIds, count: i64) -> Vec { + let mut records = Vec::new(); + for revision in 0..count { + let command = TemplateCommand { + request_id: Uuid::now_v7(), + expected_access_revision: revision, + action: TemplateAction::Create { + name: format!("Historical template {revision}"), + grants: BuiltInProfile::Member.selection().iter().collect(), + }, + }; + super::super::templates::execute(pool, ids.org_id, ids.user_id, &command) + .await + .unwrap(); + records.push(receipt_id(pool, command.request_id).await); + } + sqlx::query!( + "UPDATE permission_change_receipts SET created_at='2026-09-07 12:00:00+00' WHERE org_id=$1", + ids.org_id + ) + .execute(pool) + .await + .unwrap(); + records.sort_unstable_by(|a, b| b.cmp(a)); + records +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn history_pages_scope_before_limit_and_use_stable_exclusive_timestamp_id_bounds( + pool: PgPool, +) { + let ids = fixture(&pool, true).await; + let expected = receipts(&pool, &ids, 27).await; + let foreign = fixture(&pool, true).await; + let foreign_ids = receipts(&pool, &foreign, 3).await; + let first = page(&pool, ids.org_id, ids.user_id, None, None) + .await + .unwrap(); + assert_eq!( + first.requester, + PermissionRequester { + org_id: ids.org_id, + user_id: ids.user_id + } + ); + assert_eq!( + first + .entries + .iter() + .map(|entry| entry.id) + .collect::>(), + expected[..25] + ); + let cursor = first.next_after.unwrap(); + assert_eq!(cursor.id, expected[24]); + let second = page( + &pool, + ids.org_id, + ids.user_id, + Some(&cursor), + Some(first.requester), + ) + .await + .unwrap(); + assert_eq!( + second + .entries + .iter() + .map(|entry| entry.id) + .collect::>(), + expected[25..] + ); + assert!(second.next_after.is_none()); + for entry in first.entries.iter().chain(&second.entries) { + assert_eq!( + Some(entry.clone()), + read(&pool, ids.org_id, ids.user_id, entry.id) + .await + .unwrap() + ); + let wire = serde_json::to_value(entry).unwrap(); + for private in ["request_id", "intent", "result"] { + assert!(wire.get(private).is_none()); + } + } + // Neither a foreign receipt nor a nonexistent ID must be resolved as a record. + for id in [foreign_ids[0], Uuid::from_u128(u128::MAX)] { + let bound = AuditCursor { + created_at: cursor.created_at, + id, + }; + let bounded = page(&pool, ids.org_id, ids.user_id, Some(&bound), None) + .await + .unwrap(); + let expected: Vec<_> = expected + .iter() + .copied() + .filter(|entry| *entry < id) + .take(25) + .collect(); + assert_eq!( + bounded + .entries + .iter() + .map(|entry| entry.id) + .collect::>(), + expected + ); + } + let oldest = second.entries.last().unwrap(); + let end = AuditCursor { + created_at: oldest.created_at, + id: oldest.id, + }; + let empty = page(&pool, ids.org_id, ids.user_id, Some(&end), None) + .await + .unwrap(); + assert!(empty.entries.is_empty() && empty.next_after.is_none()); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn history_page_rejects_malformed_history_without_partial_success(pool: PgPool) { + let ids = fixture(&pool, true).await; + let records = receipts(&pool, &ids, 3).await; + sqlx::query!( + "UPDATE permission_change_receipts SET audit='{}'::jsonb WHERE id=$1", + records[1] + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + page(&pool, ids.org_id, ids.user_id, None, None).await, + Err(AuditReadError::InvalidDocument) + )); + assert!( + read(&pool, ids.org_id, ids.user_id, records[0]) + .await + .unwrap() + .is_some() + ); + assert_unlocked(&pool, &ids).await; +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn history_page_reauthorizes_empty_and_continuation_requests_and_binds_requester( + pool: PgPool, +) { + let ids = fixture(&pool, true).await; + let empty = page(&pool, ids.org_id, ids.user_id, None, None) + .await + .unwrap(); + assert!(empty.entries.is_empty() && empty.next_after.is_none()); + let nonadmin = person(&pool, ids.org_id, false, BuiltInProfile::Administrator).await; + for expected in [ + PermissionRequester { + org_id: Uuid::now_v7(), + user_id: ids.user_id, + }, + PermissionRequester { + org_id: ids.org_id, + user_id: nonadmin, + }, + ] { + assert!(matches!( + page(&pool, ids.org_id, ids.user_id, None, Some(expected)).await, + Err(AuditReadError::Forbidden) + )); + } + assert!(matches!( + page(&pool, ids.org_id, nonadmin, None, None).await, + Err(AuditReadError::Forbidden) + )); + let records = receipts(&pool, &ids, 26).await; + let first = page(&pool, ids.org_id, ids.user_id, None, None) + .await + .unwrap(); + assert_eq!(first.entries[0].id, records[0]); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator=false WHERE user_id=$1", + ids.user_id + ) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + page( + &pool, + ids.org_id, + ids.user_id, + first.next_after.as_ref(), + Some(first.requester) + ) + .await, + Err(AuditReadError::Forbidden) + )); +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn history_page_waits_for_revocation_commit_or_rollback_at_each_gate(pool: PgPool) { + for direct_activity in [false, true] { + for commit in [false, true] { + let ids = fixture(&pool, true).await; + receipts(&pool, &ids, 1).await; + let mut change = pool.begin().await.unwrap(); + if direct_activity { + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&mut *change) + .await + .unwrap(); + } else { + crate::db::lock_organization( + &mut change, + ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator=false WHERE user_id=$1", + ids.user_id + ) + .execute(&mut *change) + .await + .unwrap(); + } + let blocker = sqlx::query_scalar!("SELECT pg_backend_pid() AS \"pid!\"") + .fetch_one(&mut *change) + .await + .unwrap(); + let db = pool.clone(); + let mut reader = tokio::task::JoinSet::new(); + reader.spawn(async move { page(&db, ids.org_id, ids.user_id, None, None).await }); + wait_for_blocked(&pool, blocker).await; + if commit { + change.commit().await.unwrap(); + } else { + change.rollback().await.unwrap(); + } + let result = reader.join_next().await.unwrap().unwrap(); + if commit { + assert!(matches!(result, Err(AuditReadError::Forbidden))); + } else { + assert_eq!(result.unwrap().entries.len(), 1); + } + } + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn admitted_history_page_finishes_before_revocation_at_each_gate(pool: PgPool) { + for direct_activity in [false, true] { + let ids = fixture(&pool, true).await; + let records = receipts(&pool, &ids, 1).await; + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid() AS \"pid!\"") + .fetch_one(&mut *hold) + .await + .unwrap(); + let db = pool.clone(); + let mut reader = tokio::task::JoinSet::new(); + reader.spawn(async move { page(&db, ids.org_id, ids.user_id, None, None).await }); + wait_for_blocked(&pool, holder).await; + let reader_pid=sqlx::query_scalar!("SELECT pid FROM pg_stat_activity WHERE datname=current_database() AND $1=ANY(pg_blocking_pids(pid))",holder).fetch_one(&pool).await.unwrap().unwrap(); + let db = pool.clone(); + let mut writer = tokio::task::JoinSet::new(); + writer.spawn(async move { + let mut tx = db.begin().await.unwrap(); + if direct_activity { + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&mut *tx) + .await + .unwrap(); + } else { + crate::db::lock_organization( + &mut tx, + ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + sqlx::query!( + "UPDATE person_permission_states SET is_administrator=false WHERE user_id=$1", + ids.user_id + ) + .execute(&mut *tx) + .await + .unwrap(); + } + tx.commit().await.unwrap(); + }); + wait_for_blocked(&pool, reader_pid).await; + hold.commit().await.unwrap(); + assert_eq!( + reader.join_next().await.unwrap().unwrap().unwrap().entries[0].id, + records[0] + ); + writer.join_next().await.unwrap().unwrap(); + assert!(matches!( + page(&pool, ids.org_id, ids.user_id, None, None).await, + Err(AuditReadError::Forbidden) + )); + } +} + +#[sqlx::test(migrations = "./migrations")] +#[serial_test::serial] +async fn cancelling_history_page_releases_both_authority_gates(pool: PgPool) { + let ids = fixture(&pool, true).await; + receipts(&pool, &ids, 1).await; + let mut hold = pool.begin().await.unwrap(); + sqlx::query!("LOCK TABLE permission_change_receipts IN ACCESS EXCLUSIVE MODE") + .execute(&mut *hold) + .await + .unwrap(); + let holder = sqlx::query_scalar!("SELECT pg_backend_pid() AS \"pid!\"") + .fetch_one(&mut *hold) + .await + .unwrap(); + let db = pool.clone(); + let mut reader = tokio::task::JoinSet::new(); + reader.spawn(async move { page(&db, ids.org_id, ids.user_id, None, None).await }); + wait_for_blocked(&pool, holder).await; + reader.abort_all(); + assert!( + reader + .join_next() + .await + .unwrap() + .unwrap_err() + .is_cancelled() + ); + hold.rollback().await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + let mut tx = pool.begin().await.unwrap(); + crate::db::lock_organization( + &mut tx, + ids.org_id, + crate::db::OrganizationLock::AccessChange, + ) + .await + .unwrap(); + sqlx::query!("UPDATE users SET active=false WHERE id=$1", ids.user_id) + .execute(&mut *tx) + .await + .unwrap(); + tx.rollback().await.unwrap(); + }) + .await + .unwrap(); + assert_unlocked(&pool, &ids).await; +} diff --git a/crates/horae/tests/admin_shell.rs b/crates/horae/tests/admin_shell.rs index 6bf14bc1..d893e092 100644 --- a/crates/horae/tests/admin_shell.rs +++ b/crates/horae/tests/admin_shell.rs @@ -35,6 +35,8 @@ struct Probe { responses: Rc>>>, org_requests: Rc>, panel_mounts: Rc>, + audit: bool, + canonical_requests: Rc>, } impl Probe { @@ -46,10 +48,15 @@ impl Probe { } fn app(probe: Probe) -> Element { + let path = if probe.audit { + "/admin/audit" + } else { + "/admin/users" + }; use_context_provider(|| probe); rsx! { HistoryProvider { - history: |_| Rc::new(MemoryHistory::with_initial_path("/admin/users")) as Rc, + history: move |_| Rc::new(MemoryHistory::with_initial_path(path)) as Rc, Router:: {} } } @@ -220,6 +227,32 @@ async fn retry_waits_for_fresh_authorization_then_recovers() { mod server_fns { use super::*; + pub struct OwnPermissions { + pub catalog_version: u32, + pub is_administrator: bool, + } + + pub async fn get_my_permissions() -> Result, ServerFnError> { + let probe = consume_context::(); + probe + .canonical_requests + .set(probe.canonical_requests.get() + 1); + let reply = probe + .responses + .borrow_mut() + .pop_front() + .expect("unexpected canonical read"); + reply + .await + .expect("resolve canonical authorization") + .map(|user| { + Some(OwnPermissions { + catalog_version: horae_core::permissions::catalog::PERMISSION_CATALOG_VERSION, + is_administrator: user.is_admin(), + }) + }) + } + pub async fn get_me() -> AuthResponse { let response = consume_context::() .responses @@ -282,6 +315,8 @@ mod route { AdminUsers {}, #[route("/admin/importers")] HarvestImport {}, + #[route("/admin/audit")] + PermissionAudit {}, } pub fn route_is_active(to: &Route) -> bool { @@ -306,4 +341,62 @@ mod route { fn HarvestImport() -> Element { rsx! { "Importer panel" } } + + #[component] + fn PermissionAudit() -> Element { + rsx! { "Permission audit panel" } + } +} + +#[tokio::test] +async fn audit_route_uses_canonical_authority_without_replacing_legacy_page_checks() { + for allowed in [true, false] { + let probe = Probe { + audit: true, + ..Probe::default() + }; + let response = probe.request(); + let mut dom = start(&probe); + assert_panel_hidden(&dom, &probe); + response + .send(Ok(User(if allowed { + OrgRole::Admin + } else { + OrgRole::Member + }))) + .unwrap_or_else(|_| panic!("audit request dropped")); + settle(&mut dom); + assert_eq!(probe.canonical_requests.get(), 1); + assert_eq!( + dioxus::ssr::render(&dom).contains("Permission audit panel"), + allowed + ); + } + let probe = Probe::default(); + let response = probe.request(); + let mut dom = start(&probe); + response + .send(Ok(User(OrgRole::Member))) + .unwrap_or_else(|_| panic!("legacy request dropped")); + settle(&mut dom); + assert_panel_hidden(&dom, &probe); + assert_eq!(probe.canonical_requests.get(), 0); +} + +#[tokio::test] +async fn audit_gate_hides_internal_authentication_failures() { + let probe = Probe { + audit: true, + ..Probe::default() + }; + let response = probe.request(); + let mut dom = start(&probe); + response + .send(Err(ServerFnError::new("private SQL diagnostic"))) + .unwrap_or_else(|_| panic!("audit request dropped")); + settle(&mut dom); + assert_panel_hidden(&dom, &probe); + let html = dioxus::ssr::render(&dom); + assert!(!html.contains("private SQL diagnostic")); + assert!(html.contains("Could not verify Administrator access")); } diff --git a/crates/horae/tests/permission_audit_ui.rs b/crates/horae/tests/permission_audit_ui.rs new file mode 100644 index 00000000..3daf2997 --- /dev/null +++ b/crates/horae/tests/permission_audit_ui.rs @@ -0,0 +1,395 @@ +#![cfg(feature = "server")] + +use std::any::Any; +use std::cell::RefCell; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; + +use dioxus::core::{AttributeValue, ElementId, Mutation}; +use dioxus::prelude::*; +use dioxus_html::SerializedHtmlEventConverter; +use futures_util::FutureExt; +use tokio::sync::oneshot; +use uuid::Uuid; + +#[path = "../src/components/permission_description.rs"] +pub mod permission_description; +mod components { + pub use super::permission_description; +} +#[path = "../src/models/permission_audit.rs"] +pub mod permission_audit; +#[path = "../src/models/permission_editor.rs"] +pub mod permission_editor; +mod models { + pub use super::{permission_audit, permission_editor}; +} +#[path = "../src/pages/permission_audit.rs"] +mod page; + +use permission_audit::*; +use permission_editor::PermissionRequester; +type Response = Result; +type Request = (Option, Option); + +#[derive(Clone, Default)] +struct Probe { + replies: Rc>>>, + requests: Rc>>, +} + +impl Probe { + fn reply(&self) -> oneshot::Sender { + let (send, receive) = oneshot::channel(); + self.replies.borrow_mut().push_back(receive); + send + } +} + +fn app(probe: Probe) -> Element { + use_context_provider(|| probe); + rsx! { page::PermissionAudit {} } +} + +struct Harness { + dom: VirtualDom, + controls: HashMap, +} + +impl Harness { + fn new(probe: &Probe) -> Self { + set_event_converter(Box::new(SerializedHtmlEventConverter)); + let mut dom = VirtualDom::new_with_props(app, probe.clone()); + let edits = dom.rebuild_to_vec().edits; + let mut harness = Self { + dom, + controls: HashMap::new(), + }; + harness.record(edits); + harness.settle(); + harness + } + + fn record(&mut self, edits: Vec) { + for edit in edits { + if let Mutation::SetAttribute { + name: "id", + value: AttributeValue::Text(value), + id, + .. + } = edit + { + self.controls.insert(value, id); + } + } + } + + fn settle(&mut self) { + for _ in 0..30 { + if self.dom.wait_for_work().now_or_never().is_none() { + return; + } + let edits = self.dom.render_immediate_to_vec().edits; + self.record(edits); + } + panic!("audit resource did not settle"); + } + + fn click(&mut self, name: &str) { + let event = Event::new( + Rc::new(PlatformEventData::new(Box::::default())) as Rc, + true, + ); + self.dom + .runtime() + .handle_event("click", event, self.controls[name]); + self.settle(); + } + + fn html(&self) -> String { + dioxus::ssr::render(&self.dom) + } +} + +fn requester() -> PermissionRequester { + PermissionRequester { + org_id: Uuid::now_v7(), + user_id: Uuid::now_v7(), + } +} + +fn history(requester: PermissionRequester, older: bool) -> AuditPage { + let entry = AuditEntry { + id: Uuid::now_v7(), + actor: AuditPrincipal::User { + user_id: requester.user_id, + }, + created_at: "2026-10-04T10:00:00Z".parse().unwrap(), + audit: HistoricalAudit::Profile(ProfileAudit { + user_id: Uuid::now_v7(), + previous_access_revision: 7, + access_revision: 7, + change: None, + }), + }; + AuditPage { + requester, + next_after: older.then_some(AuditCursor { + created_at: entry.created_at, + id: entry.id, + }), + entries: vec![entry], + } +} + +#[tokio::test] +async fn pages_bind_requester_replace_history_and_hide_stale_content_during_refresh() { + let probe = Probe::default(); + let first = probe.reply(); + let mut h = Harness::new(&probe); + assert_eq!(*probe.requests.borrow(), vec![(None, None)]); + assert!(h.html().contains("Loading permission history")); + h.click("audit-refresh"); + assert_eq!(probe.requests.borrow().len(), 1); + let owner = requester(); + let page = history(owner, true); + let id = page.entries[0].id.to_string(); + let cursor = page.next_after; + first.send(Ok(page)).unwrap(); + h.settle(); + assert!(h.html().contains(&id)); + assert!(h.html().contains("No permission change")); + let second = probe.reply(); + h.click("audit-older"); + assert!(!h.html().contains(&id)); + assert_eq!(probe.requests.borrow()[1], (cursor, Some(owner))); + second + .send(Ok(AuditPage { + requester: owner, + entries: vec![], + next_after: None, + })) + .unwrap(); + h.settle(); + assert!(h.html().contains("No older permission events")); + assert!(!h.html().contains(&id)); + let refresh = probe.reply(); + h.click("audit-newest"); + assert_eq!(probe.requests.borrow()[2], (None, Some(owner))); + refresh + .send(Err(ServerFnError::ServerError { + code: 403, + message: "private diagnostic".into(), + details: None, + })) + .unwrap(); + h.settle(); + assert!(!h.html().contains("private diagnostic")); + assert!(h.html().contains("Administrator access")); + let retry = probe.reply(); + h.click("audit-refresh"); + retry.send(Ok(history(owner, false))).unwrap(); + h.settle(); + assert!(h.html().contains("No more permission events")); +} + +#[tokio::test] +async fn different_requester_response_is_never_rendered_or_adopted() { + let probe = Probe::default(); + let first = probe.reply(); + let mut h = Harness::new(&probe); + let owner = requester(); + first.send(Ok(history(owner, false))).unwrap(); + h.settle(); + let refresh = probe.reply(); + h.click("audit-refresh"); + let other = history(requester(), false); + let other_id = other.entries[0].id.to_string(); + refresh.send(Ok(other)).unwrap(); + h.settle(); + assert!(!h.html().contains(&other_id)); + assert!(h.html().contains("Administrator access")); + let _retry = probe.reply(); + h.click("audit-refresh"); + assert_eq!(probe.requests.borrow().last().unwrap().1, Some(owner)); +} + +mod server_fns { + use super::*; + pub async fn list_permission_audit( + after: Option, + expected: Option, + ) -> Response { + let probe = consume_context::(); + probe.requests.borrow_mut().push((after, expected)); + let reply = probe + .replies + .borrow_mut() + .pop_front() + .expect("unexpected history request"); + reply.await.expect("resolve controlled history response") + } +} + +#[tokio::test] +async fn history_details_preserve_recorded_grants_sources_detachment_and_operator_identity() { + use horae_core::permissions::catalog::{BuiltInProfile, Permission}; + let probe = Probe::default(); + let response = probe.reply(); + let mut h = Harness::new(&probe); + let mut page = history(requester(), false); + let person_id = Uuid::now_v7(); + let template_id = Uuid::now_v7(); + let project_id = Uuid::now_v7(); + let manager_id = Uuid::now_v7(); + let entry = &mut page.entries[0]; + entry.actor = AuditPrincipal::Operator { + invocation_id: "recorded invocation".into(), + command: "permission migration".into(), + }; + entry.audit = HistoricalAudit::Profile(ProfileAudit { + user_id: person_id, + previous_access_revision: 7, + access_revision: 8, + change: Some(ProfileChange { + catalog_version: 1, + before: PersonSnapshot { + grants: vec![Permission::TimeReadOwn], + is_administrator: true, + source: HistoricalSource::BuiltIn(BuiltInProfile::Administrator), + revision: 1, + }, + after: PersonSnapshot { + grants: vec![Permission::TimeReadManaged], + is_administrator: false, + source: HistoricalSource::Template { + id: template_id, + applied_revision: 3, + }, + revision: 2, + }, + removed_projects: vec![RemovedRelationship { + id: Uuid::now_v7(), + subject_id: project_id, + revision: 2, + }], + removed_people: vec![RemovedRelationship { + id: Uuid::now_v7(), + subject_id: manager_id, + revision: 1, + }], + }), + }); + let mut deleted = entry.clone(); + deleted.id = Uuid::now_v7(); + deleted.audit = HistoricalAudit::Template(TemplateAudit { + previous_access_revision: 8, + access_revision: 9, + after: None, + before: Some(TemplateSnapshot { + id: template_id, + name: "".into(), + catalog_version: 1, + grants: vec![Permission::TimeReadOwn], + revision: 3, + }), + detached_people: vec![DetachedPerson { + user_id: person_id, + catalog_version: 1, + grants: vec![Permission::TimeReadOwn], + is_administrator: false, + previous_template_id: template_id, + previous_applied_revision: 3, + previous_revision: 2, + revision: 3, + }], + }); + let mut managers = entry.clone(); + managers.id = Uuid::now_v7(); + managers.audit = HistoricalAudit::ProjectManagers(ProjectAudit { + project_id, + previous_access_revision: 9, + access_revision: 10, + change: Some(ProjectChange { + added: vec![Designation { + id: Uuid::now_v7(), + manager_id, + revision: 1, + }], + removed: vec![Designation { + id: Uuid::now_v7(), + manager_id: person_id, + revision: 2, + }], + }), + }); + page.entries.extend([deleted, managers]); + response.send(Ok(page)).unwrap(); + h.settle(); + let html = h.html(); + for expected in [ + "Before", + "After", + "Administrator: Yes", + "Administrator: No", + "View your own time", + "View time for managed people and projects", + "applied revision 3", + "Custom profile deleted", + "Detached person", + "individual configuration", + "Removed managed project", + "Removed managed person", + "Added manager", + "Removed manager", + "permission migration", + "recorded invocation", + "UTC", + ] { + assert!(html.contains(expected), "missing {expected}: {html}"); + } + assert!(html.contains(&project_id.to_string())); + assert!(html.contains(&person_id.to_string())); + assert!( + html.contains("<script>not markup</script>"), + "{html}" + ); + assert!(!html.contains("")); + assert_eq!(html.matches(" Date: Tue, 6 Oct 2026 21:54:01 +0200 Subject: [PATCH 2/6] Cache permission history queries --- ...6a5deb350dfaecf909f192e85126ea3366ddb.json | 22 +++++++ ...9a30fa9e369c9c7996f5252641de7b711fc29.json | 14 +++++ ...dbdbd3ca7f84f519369213da50c61017cbdc4.json | 12 ++++ ...84d7ff3d49661dc63b9a6fcc00cec3b4e9ac1.json | 14 +++++ ...93938dfc54800b465d7c37ebc585bb2892397.json | 59 ++++++++++++++++++ ...c51d5d6a05c49fc7c30d673ca655b32bfb653.json | 14 +++++ ...0f6a52d9a40428ba98955dff648f12a80ab43.json | 14 +++++ ...60c6cff8627bc5cb3857a6723fee1d9d043a8.json | 14 +++++ ...308ee157762406b212c3a618f1fdb08ca572d.json | 14 +++++ ...ec76fb50bccfdaf736d20b981ebdc56f11397.json | 16 +++++ ...ce0d8da7f8eeee414b3c8c977a90d05bcb0b3.json | 22 +++++++ ...d16f5d5390f878d85ca1d3c801156a8798069.json | 16 +++++ ...294a6b4a228bbfc73986a61f77d11edb91115.json | 24 ++++++++ ...5a8f8e75f38e8edede1471ebcb17216023e82.json | 17 ++++++ ...7d732ac55120a72df7d5a193cb60f43b9d4d4.json | 17 ++++++ ...0d206c9188189b68bb9a11d48b9aaffcf48f7.json | 28 +++++++++ ...0a3af2bd38085b75074478564423892923e76.json | 20 +++++++ ...9495339e32d63e558481a04f2e0fcf8293c95.json | 60 +++++++++++++++++++ ...f27981c317a1d523601bca734138e0a41cfb5.json | 22 +++++++ ...c6cddaa12d541341cf2f17193c2b888e29948.json | 14 +++++ ...5398fe88bcc745a4b7509e3289ca7035fe4af.json | 17 ++++++ ...6b84d9e887d0b6071ac3d92e6ef131ff74ca0.json | 14 +++++ ...2777d9d075bb7be7b7a9e46dd30a6079e3090.json | 14 +++++ ...187364ebc05eecba2fed9625d576bfdae6865.json | 18 ++++++ ...9ec03f0d01e4cc6b8386c6e60faa25a87d002.json | 29 +++++++++ ...c88c9b5294cdfe77ab047198d35b399d7853a.json | 17 ++++++ ...270ad13f9401d290b06d744c00bcf4507e6c8.json | 28 +++++++++ ...5672cdcf579f4c3e26fb2eef7b5fb22051c7b.json | 22 +++++++ 28 files changed, 592 insertions(+) create mode 100644 .sqlx/query-0bb70599ad08a40d4e8d21fa62c6a5deb350dfaecf909f192e85126ea3366ddb.json create mode 100644 .sqlx/query-291ab6311e071932f954411057d9a30fa9e369c9c7996f5252641de7b711fc29.json create mode 100644 .sqlx/query-2962a43fd17841a5eb5403846dfdbdbd3ca7f84f519369213da50c61017cbdc4.json create mode 100644 .sqlx/query-2a7ada9f06f1fe92279e9a3adc984d7ff3d49661dc63b9a6fcc00cec3b4e9ac1.json create mode 100644 .sqlx/query-3293bd519278cbe3ed3c63214de93938dfc54800b465d7c37ebc585bb2892397.json create mode 100644 .sqlx/query-37f585db1a32b0999207531c7bbc51d5d6a05c49fc7c30d673ca655b32bfb653.json create mode 100644 .sqlx/query-51d12737c6fc39e61c1e13458510f6a52d9a40428ba98955dff648f12a80ab43.json create mode 100644 .sqlx/query-53cddc0c84bc54db4ae633ecc6160c6cff8627bc5cb3857a6723fee1d9d043a8.json create mode 100644 .sqlx/query-57248ee3a35e31177effe8f319e308ee157762406b212c3a618f1fdb08ca572d.json create mode 100644 .sqlx/query-59411874ad65c1cf60d3a6199feec76fb50bccfdaf736d20b981ebdc56f11397.json create mode 100644 .sqlx/query-9306fd4838a8aa7b80f15562306ce0d8da7f8eeee414b3c8c977a90d05bcb0b3.json create mode 100644 .sqlx/query-9ef4e281a6e5ab2a0ef03295099d16f5d5390f878d85ca1d3c801156a8798069.json create mode 100644 .sqlx/query-a21ba4c33c35f730f6628d6c86a294a6b4a228bbfc73986a61f77d11edb91115.json create mode 100644 .sqlx/query-afa7d224755d53a0c8e2bbf0f405a8f8e75f38e8edede1471ebcb17216023e82.json create mode 100644 .sqlx/query-bc24ce673ec0c9eeb1fbec56fcd7d732ac55120a72df7d5a193cb60f43b9d4d4.json create mode 100644 .sqlx/query-bf6cfbe8a8ae0a1b5fc26d023530d206c9188189b68bb9a11d48b9aaffcf48f7.json create mode 100644 .sqlx/query-d23da7f70eef3ad703de88657700a3af2bd38085b75074478564423892923e76.json create mode 100644 .sqlx/query-d63ed3c69fa8ccf74a25f750c899495339e32d63e558481a04f2e0fcf8293c95.json create mode 100644 .sqlx/query-d9c89bf406ea859e01cf47e8354f27981c317a1d523601bca734138e0a41cfb5.json create mode 100644 .sqlx/query-e4d9102fa4ef95c41d72a686208c6cddaa12d541341cf2f17193c2b888e29948.json create mode 100644 .sqlx/query-e7bbd2c9afe30142ef4317be9635398fe88bcc745a4b7509e3289ca7035fe4af.json create mode 100644 .sqlx/query-e9968673946976d102898d7ac6f6b84d9e887d0b6071ac3d92e6ef131ff74ca0.json create mode 100644 .sqlx/query-ec5dc173ae794b97dbccd4555cf2777d9d075bb7be7b7a9e46dd30a6079e3090.json create mode 100644 .sqlx/query-ec644e02efd2c80cf3696f39b5b187364ebc05eecba2fed9625d576bfdae6865.json create mode 100644 .sqlx/query-f29f478d8be6d2ab53e95b0cdb59ec03f0d01e4cc6b8386c6e60faa25a87d002.json create mode 100644 .sqlx/query-f4960301ab09f2c4e8508f785b2c88c9b5294cdfe77ab047198d35b399d7853a.json create mode 100644 .sqlx/query-f78ded569baba4c60745d136366270ad13f9401d290b06d744c00bcf4507e6c8.json create mode 100644 .sqlx/query-f813098a4bcc869e9fc932a5b175672cdcf579f4c3e26fb2eef7b5fb22051c7b.json diff --git a/.sqlx/query-0bb70599ad08a40d4e8d21fa62c6a5deb350dfaecf909f192e85126ea3366ddb.json b/.sqlx/query-0bb70599ad08a40d4e8d21fa62c6a5deb350dfaecf909f192e85126ea3366ddb.json new file mode 100644 index 00000000..2ab13d6c --- /dev/null +++ b/.sqlx/query-0bb70599ad08a40d4e8d21fa62c6a5deb350dfaecf909f192e85126ea3366ddb.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pid FROM pg_stat_activity WHERE datname = current_database()\n AND $1 = ANY(pg_blocking_pids(pid))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pid", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + true + ] + }, + "hash": "0bb70599ad08a40d4e8d21fa62c6a5deb350dfaecf909f192e85126ea3366ddb" +} diff --git a/.sqlx/query-291ab6311e071932f954411057d9a30fa9e369c9c7996f5252641de7b711fc29.json b/.sqlx/query-291ab6311e071932f954411057d9a30fa9e369c9c7996f5252641de7b711fc29.json new file mode 100644 index 00000000..8b38394d --- /dev/null +++ b/.sqlx/query-291ab6311e071932f954411057d9a30fa9e369c9c7996f5252641de7b711fc29.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET created_at='2026-09-07 12:00:00+00' WHERE org_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "291ab6311e071932f954411057d9a30fa9e369c9c7996f5252641de7b711fc29" +} diff --git a/.sqlx/query-2962a43fd17841a5eb5403846dfdbdbd3ca7f84f519369213da50c61017cbdc4.json b/.sqlx/query-2962a43fd17841a5eb5403846dfdbdbd3ca7f84f519369213da50c61017cbdc4.json new file mode 100644 index 00000000..5ff10562 --- /dev/null +++ b/.sqlx/query-2962a43fd17841a5eb5403846dfdbdbd3ca7f84f519369213da50c61017cbdc4.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "LOCK TABLE users IN ACCESS EXCLUSIVE MODE", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "2962a43fd17841a5eb5403846dfdbdbd3ca7f84f519369213da50c61017cbdc4" +} diff --git a/.sqlx/query-2a7ada9f06f1fe92279e9a3adc984d7ff3d49661dc63b9a6fcc00cec3b4e9ac1.json b/.sqlx/query-2a7ada9f06f1fe92279e9a3adc984d7ff3d49661dc63b9a6fcc00cec3b4e9ac1.json new file mode 100644 index 00000000..dce26d97 --- /dev/null +++ b/.sqlx/query-2a7ada9f06f1fe92279e9a3adc984d7ff3d49661dc63b9a6fcc00cec3b4e9ac1.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET format_version=1 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "2a7ada9f06f1fe92279e9a3adc984d7ff3d49661dc63b9a6fcc00cec3b4e9ac1" +} diff --git a/.sqlx/query-3293bd519278cbe3ed3c63214de93938dfc54800b465d7c37ebc585bb2892397.json b/.sqlx/query-3293bd519278cbe3ed3c63214de93938dfc54800b465d7c37ebc585bb2892397.json new file mode 100644 index 00000000..ea99e57b --- /dev/null +++ b/.sqlx/query-3293bd519278cbe3ed3c63214de93938dfc54800b465d7c37ebc585bb2892397.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, actor_user_id, operator_id, operator_command, format_version, audit,\n created_at as \"created_at: DateTime\"\n FROM permission_change_receipts WHERE org_id = $1 AND id = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "actor_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "operator_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "operator_command", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "format_version", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "audit", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "created_at: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + false, + false + ] + }, + "hash": "3293bd519278cbe3ed3c63214de93938dfc54800b465d7c37ebc585bb2892397" +} diff --git a/.sqlx/query-37f585db1a32b0999207531c7bbc51d5d6a05c49fc7c30d673ca655b32bfb653.json b/.sqlx/query-37f585db1a32b0999207531c7bbc51d5d6a05c49fc7c30d673ca655b32bfb653.json new file mode 100644 index 00000000..ccaf4ddf --- /dev/null +++ b/.sqlx/query-37f585db1a32b0999207531c7bbc51d5d6a05c49fc7c30d673ca655b32bfb653.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET format_version=99 WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "37f585db1a32b0999207531c7bbc51d5d6a05c49fc7c30d673ca655b32bfb653" +} diff --git a/.sqlx/query-51d12737c6fc39e61c1e13458510f6a52d9a40428ba98955dff648f12a80ab43.json b/.sqlx/query-51d12737c6fc39e61c1e13458510f6a52d9a40428ba98955dff648f12a80ab43.json new file mode 100644 index 00000000..a03eb4ee --- /dev/null +++ b/.sqlx/query-51d12737c6fc39e61c1e13458510f6a52d9a40428ba98955dff648f12a80ab43.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET audit='{}'::jsonb WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "51d12737c6fc39e61c1e13458510f6a52d9a40428ba98955dff648f12a80ab43" +} diff --git a/.sqlx/query-53cddc0c84bc54db4ae633ecc6160c6cff8627bc5cb3857a6723fee1d9d043a8.json b/.sqlx/query-53cddc0c84bc54db4ae633ecc6160c6cff8627bc5cb3857a6723fee1d9d043a8.json new file mode 100644 index 00000000..c70cd67a --- /dev/null +++ b/.sqlx/query-53cddc0c84bc54db4ae633ecc6160c6cff8627bc5cb3857a6723fee1d9d043a8.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET is_administrator=false WHERE user_id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "53cddc0c84bc54db4ae633ecc6160c6cff8627bc5cb3857a6723fee1d9d043a8" +} diff --git a/.sqlx/query-57248ee3a35e31177effe8f319e308ee157762406b212c3a618f1fdb08ca572d.json b/.sqlx/query-57248ee3a35e31177effe8f319e308ee157762406b212c3a618f1fdb08ca572d.json new file mode 100644 index 00000000..95a899c8 --- /dev/null +++ b/.sqlx/query-57248ee3a35e31177effe8f319e308ee157762406b212c3a618f1fdb08ca572d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET format_version = 1, audit = '{}'::jsonb WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "57248ee3a35e31177effe8f319e308ee157762406b212c3a618f1fdb08ca572d" +} diff --git a/.sqlx/query-59411874ad65c1cf60d3a6199feec76fb50bccfdaf736d20b981ebdc56f11397.json b/.sqlx/query-59411874ad65c1cf60d3a6199feec76fb50bccfdaf736d20b981ebdc56f11397.json new file mode 100644 index 00000000..7657392c --- /dev/null +++ b/.sqlx/query-59411874ad65c1cf60d3a6199feec76fb50bccfdaf736d20b981ebdc56f11397.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO permission_change_receipts\n (id, org_id, operator_id, operator_command, request_id, format_version, intent, result, audit)\n SELECT $1, org_id, 'operator-invocation', 'permission.change', $2, format_version,\n '{\"private_input\":\"sentinel\"}'::jsonb, '{\"private_result\":\"sentinel\"}'::jsonb, audit\n FROM permission_change_receipts WHERE id = $3", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "59411874ad65c1cf60d3a6199feec76fb50bccfdaf736d20b981ebdc56f11397" +} diff --git a/.sqlx/query-9306fd4838a8aa7b80f15562306ce0d8da7f8eeee414b3c8c977a90d05bcb0b3.json b/.sqlx/query-9306fd4838a8aa7b80f15562306ce0d8da7f8eeee414b3c8c977a90d05bcb0b3.json new file mode 100644 index 00000000..3d5c34a8 --- /dev/null +++ b/.sqlx/query-9306fd4838a8aa7b80f15562306ce0d8da7f8eeee414b3c8c977a90d05bcb0b3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_cancel_backend(pid) FROM pg_stat_activity WHERE datname=current_database() AND $1 = ANY(pg_blocking_pids(pid))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_cancel_backend", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9306fd4838a8aa7b80f15562306ce0d8da7f8eeee414b3c8c977a90d05bcb0b3" +} diff --git a/.sqlx/query-9ef4e281a6e5ab2a0ef03295099d16f5d5390f878d85ca1d3c801156a8798069.json b/.sqlx/query-9ef4e281a6e5ab2a0ef03295099d16f5d5390f878d85ca1d3c801156a8798069.json new file mode 100644 index 00000000..e177605b --- /dev/null +++ b/.sqlx/query-9ef4e281a6e5ab2a0ef03295099d16f5d5390f878d85ca1d3c801156a8798069.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (id, org_id, email, name) VALUES ($1, $2, $3, 'Audit person')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "9ef4e281a6e5ab2a0ef03295099d16f5d5390f878d85ca1d3c801156a8798069" +} diff --git a/.sqlx/query-a21ba4c33c35f730f6628d6c86a294a6b4a228bbfc73986a61f77d11edb91115.json b/.sqlx/query-a21ba4c33c35f730f6628d6c86a294a6b4a228bbfc73986a61f77d11edb91115.json new file mode 100644 index 00000000..f035550b --- /dev/null +++ b/.sqlx/query-a21ba4c33c35f730f6628d6c86a294a6b4a228bbfc73986a61f77d11edb91115.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM permission_change_receipts WHERE org_id=$1 AND actor_user_id=$2 AND request_id=$3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a21ba4c33c35f730f6628d6c86a294a6b4a228bbfc73986a61f77d11edb91115" +} diff --git a/.sqlx/query-afa7d224755d53a0c8e2bbf0f405a8f8e75f38e8edede1471ebcb17216023e82.json b/.sqlx/query-afa7d224755d53a0c8e2bbf0f405a8f8e75f38e8edede1471ebcb17216023e82.json new file mode 100644 index 00000000..97740597 --- /dev/null +++ b/.sqlx/query-afa7d224755d53a0c8e2bbf0f405a8f8e75f38e8edede1471ebcb17216023e82.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO project_management_assignments (id, org_id, manager_id, project_id)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "afa7d224755d53a0c8e2bbf0f405a8f8e75f38e8edede1471ebcb17216023e82" +} diff --git a/.sqlx/query-bc24ce673ec0c9eeb1fbec56fcd7d732ac55120a72df7d5a193cb60f43b9d4d4.json b/.sqlx/query-bc24ce673ec0c9eeb1fbec56fcd7d732ac55120a72df7d5a193cb60f43b9d4d4.json new file mode 100644 index 00000000..a747dcf0 --- /dev/null +++ b/.sqlx/query-bc24ce673ec0c9eeb1fbec56fcd7d732ac55120a72df7d5a193cb60f43b9d4d4.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source)\n VALUES ($1,$2,$3,1,$4,true,'individual')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "bc24ce673ec0c9eeb1fbec56fcd7d732ac55120a72df7d5a193cb60f43b9d4d4" +} diff --git a/.sqlx/query-bf6cfbe8a8ae0a1b5fc26d023530d206c9188189b68bb9a11d48b9aaffcf48f7.json b/.sqlx/query-bf6cfbe8a8ae0a1b5fc26d023530d206c9188189b68bb9a11d48b9aaffcf48f7.json new file mode 100644 index 00000000..b21f664c --- /dev/null +++ b/.sqlx/query-bf6cfbe8a8ae0a1b5fc26d023530d206c9188189b68bb9a11d48b9aaffcf48f7.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, audit FROM permission_change_receipts WHERE request_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "audit", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "bf6cfbe8a8ae0a1b5fc26d023530d206c9188189b68bb9a11d48b9aaffcf48f7" +} diff --git a/.sqlx/query-d23da7f70eef3ad703de88657700a3af2bd38085b75074478564423892923e76.json b/.sqlx/query-d23da7f70eef3ad703de88657700a3af2bd38085b75074478564423892923e76.json new file mode 100644 index 00000000..ebb1ff70 --- /dev/null +++ b/.sqlx/query-d23da7f70eef3ad703de88657700a3af2bd38085b75074478564423892923e76.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT current_setting('statement_timeout')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "current_setting", + "type_info": "Text" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "d23da7f70eef3ad703de88657700a3af2bd38085b75074478564423892923e76" +} diff --git a/.sqlx/query-d63ed3c69fa8ccf74a25f750c899495339e32d63e558481a04f2e0fcf8293c95.json b/.sqlx/query-d63ed3c69fa8ccf74a25f750c899495339e32d63e558481a04f2e0fcf8293c95.json new file mode 100644 index 00000000..66621922 --- /dev/null +++ b/.sqlx/query-d63ed3c69fa8ccf74a25f750c899495339e32d63e558481a04f2e0fcf8293c95.json @@ -0,0 +1,60 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id,actor_user_id,operator_id,operator_command,format_version,audit,\n created_at AS \"created_at: DateTime\"\n FROM permission_change_receipts\n WHERE org_id=$1\n AND ($2::timestamptz IS NULL OR (created_at,id)<($2,$3))\n ORDER BY created_at DESC,id DESC LIMIT 26", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "actor_user_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "operator_id", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "operator_command", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "format_version", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "audit", + "type_info": "Jsonb" + }, + { + "ordinal": 6, + "name": "created_at: DateTime", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Timestamptz", + "Uuid" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + false, + false + ] + }, + "hash": "d63ed3c69fa8ccf74a25f750c899495339e32d63e558481a04f2e0fcf8293c95" +} diff --git a/.sqlx/query-d9c89bf406ea859e01cf47e8354f27981c317a1d523601bca734138e0a41cfb5.json b/.sqlx/query-d9c89bf406ea859e01cf47e8354f27981c317a1d523601bca734138e0a41cfb5.json new file mode 100644 index 00000000..31fbdcb1 --- /dev/null +++ b/.sqlx/query-d9c89bf406ea859e01cf47e8354f27981c317a1d523601bca734138e0a41cfb5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM permission_change_receipts WHERE request_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d9c89bf406ea859e01cf47e8354f27981c317a1d523601bca734138e0a41cfb5" +} diff --git a/.sqlx/query-e4d9102fa4ef95c41d72a686208c6cddaa12d541341cf2f17193c2b888e29948.json b/.sqlx/query-e4d9102fa4ef95c41d72a686208c6cddaa12d541341cf2f17193c2b888e29948.json new file mode 100644 index 00000000..3edeb4c7 --- /dev/null +++ b/.sqlx/query-e4d9102fa4ef95c41d72a686208c6cddaa12d541341cf2f17193c2b888e29948.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE person_permission_states SET grants = ARRAY['not_a_permission'] WHERE user_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e4d9102fa4ef95c41d72a686208c6cddaa12d541341cf2f17193c2b888e29948" +} diff --git a/.sqlx/query-e7bbd2c9afe30142ef4317be9635398fe88bcc745a4b7509e3289ca7035fe4af.json b/.sqlx/query-e7bbd2c9afe30142ef4317be9635398fe88bcc745a4b7509e3289ca7035fe4af.json new file mode 100644 index 00000000..70136414 --- /dev/null +++ b/.sqlx/query-e7bbd2c9afe30142ef4317be9635398fe88bcc745a4b7509e3289ca7035fe4af.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO person_management_assignments (id, org_id, manager_id, managed_user_id)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e7bbd2c9afe30142ef4317be9635398fe88bcc745a4b7509e3289ca7035fe4af" +} diff --git a/.sqlx/query-e9968673946976d102898d7ac6f6b84d9e887d0b6071ac3d92e6ef131ff74ca0.json b/.sqlx/query-e9968673946976d102898d7ac6f6b84d9e887d0b6071ac3d92e6ef131ff74ca0.json new file mode 100644 index 00000000..afdfd5fe --- /dev/null +++ b/.sqlx/query-e9968673946976d102898d7ac6f6b84d9e887d0b6071ac3d92e6ef131ff74ca0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET format_version=1, audit='{}'::jsonb WHERE id=$1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "e9968673946976d102898d7ac6f6b84d9e887d0b6071ac3d92e6ef131ff74ca0" +} diff --git a/.sqlx/query-ec5dc173ae794b97dbccd4555cf2777d9d075bb7be7b7a9e46dd30a6079e3090.json b/.sqlx/query-ec5dc173ae794b97dbccd4555cf2777d9d075bb7be7b7a9e46dd30a6079e3090.json new file mode 100644 index 00000000..9f5ed3aa --- /dev/null +++ b/.sqlx/query-ec5dc173ae794b97dbccd4555cf2777d9d075bb7be7b7a9e46dd30a6079e3090.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE permission_change_receipts SET format_version = 99 WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "ec5dc173ae794b97dbccd4555cf2777d9d075bb7be7b7a9e46dd30a6079e3090" +} diff --git a/.sqlx/query-ec644e02efd2c80cf3696f39b5b187364ebc05eecba2fed9625d576bfdae6865.json b/.sqlx/query-ec644e02efd2c80cf3696f39b5b187364ebc05eecba2fed9625d576bfdae6865.json new file mode 100644 index 00000000..d7274d17 --- /dev/null +++ b/.sqlx/query-ec644e02efd2c80cf3696f39b5b187364ebc05eecba2fed9625d576bfdae6865.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source)\n VALUES ($1, $2, $3, 1, $4, $5, 'individual')", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "TextArray", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "ec644e02efd2c80cf3696f39b5b187364ebc05eecba2fed9625d576bfdae6865" +} diff --git a/.sqlx/query-f29f478d8be6d2ab53e95b0cdb59ec03f0d01e4cc6b8386c6e60faa25a87d002.json b/.sqlx/query-f29f478d8be6d2ab53e95b0cdb59ec03f0d01e4cc6b8386c6e60faa25a87d002.json new file mode 100644 index 00000000..95200985 --- /dev/null +++ b/.sqlx/query-f29f478d8be6d2ab53e95b0cdb59ec03f0d01e4cc6b8386c6e60faa25a87d002.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, audit FROM permission_change_receipts WHERE org_id=$1 AND request_id=$2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "audit", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "f29f478d8be6d2ab53e95b0cdb59ec03f0d01e4cc6b8386c6e60faa25a87d002" +} diff --git a/.sqlx/query-f4960301ab09f2c4e8508f785b2c88c9b5294cdfe77ab047198d35b399d7853a.json b/.sqlx/query-f4960301ab09f2c4e8508f785b2c88c9b5294cdfe77ab047198d35b399d7853a.json new file mode 100644 index 00000000..77745e82 --- /dev/null +++ b/.sqlx/query-f4960301ab09f2c4e8508f785b2c88c9b5294cdfe77ab047198d35b399d7853a.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO permission_change_receipts (id, org_id, operator_id, operator_command, request_id, format_version, intent, result, audit)\n SELECT $1, org_id, 'operator-invocation', 'permission.change', $2, format_version,\n '{\"private_input\":\"sentinel\"}'::jsonb, '{\"private_result\":\"sentinel\"}'::jsonb, audit\n FROM permission_change_receipts WHERE org_id=$3 AND request_id=$4", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Uuid", + "Uuid", + "Uuid" + ] + }, + "nullable": [] + }, + "hash": "f4960301ab09f2c4e8508f785b2c88c9b5294cdfe77ab047198d35b399d7853a" +} diff --git a/.sqlx/query-f78ded569baba4c60745d136366270ad13f9401d290b06d744c00bcf4507e6c8.json b/.sqlx/query-f78ded569baba4c60745d136366270ad13f9401d290b06d744c00bcf4507e6c8.json new file mode 100644 index 00000000..3118ff25 --- /dev/null +++ b/.sqlx/query-f78ded569baba4c60745d136366270ad13f9401d290b06d744c00bcf4507e6c8.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, audit FROM permission_change_receipts WHERE org_id=$1 ORDER BY id", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "audit", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "f78ded569baba4c60745d136366270ad13f9401d290b06d744c00bcf4507e6c8" +} diff --git a/.sqlx/query-f813098a4bcc869e9fc932a5b175672cdcf579f4c3e26fb2eef7b5fb22051c7b.json b/.sqlx/query-f813098a4bcc869e9fc932a5b175672cdcf579f4c3e26fb2eef7b5fb22051c7b.json new file mode 100644 index 00000000..d2edd197 --- /dev/null +++ b/.sqlx/query-f813098a4bcc869e9fc932a5b175672cdcf579f4c3e26fb2eef7b5fb22051c7b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM permission_change_receipts WHERE org_id=$1 ORDER BY created_at DESC,id DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f813098a4bcc869e9fc932a5b175672cdcf579f4c3e26fb2eef7b5fb22051c7b" +} From f81ba9892c2715056f6efaede191426ed0527eb0 Mon Sep 17 00:00:00 2001 From: Aldo Borrero <82811+aldoborrero@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:06:51 +0200 Subject: [PATCH 3/6] Verify permission history with real delegation receipts --- .../tests/browser/permission-history.cjs | 156 ++++++++++++++++++ .../horae/tests/browser/run-design-checks.sh | 2 +- 2 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 crates/horae/tests/browser/permission-history.cjs diff --git a/crates/horae/tests/browser/permission-history.cjs b/crates/horae/tests/browser/permission-history.cjs new file mode 100644 index 00000000..9b4fd2db --- /dev/null +++ b/crates/horae/tests/browser/permission-history.cjs @@ -0,0 +1,156 @@ +// Exercise history without depending on the separate permission editor UI. +const { chromium, expect } = require(process.env.PLAYWRIGHT_MODULE || 'playwright/test'); +const { execFileSync } = require('node:child_process'); +const { readFileSync, mkdirSync } = require('node:fs'); +const { join } = require('node:path'); +const assert = require('node:assert/strict'); +const base = process.env.HORAE_TEST_URL; +const target = new URL(base); +const database = new URL(process.env.DATABASE_URL); +assert.ok(['localhost', '127.0.0.1'].includes(target.hostname) && target.port === '8093'); +assert.equal(database.pathname, '/horae'); +assert.match(database.searchParams.get('host') || '', /^\/tmp\/horae-browser\.[A-Za-z0-9]+$/); +const sql = query => execFileSync('psql', [process.env.DATABASE_URL, '-X', '-v', 'ON_ERROR_STOP=1', '-qAt', '-c', query], { encoding: 'utf8' }).trim(); +const actor = JSON.parse(sql("SELECT row_to_json(u) FROM (SELECT id, org_id FROM users WHERE email='admin@example.com' AND active AND org_role='admin') u")); +const project = sql(`SELECT id FROM projects WHERE org_id='${actor.org_id}' AND active ORDER BY id LIMIT 1`); +for (const id of [actor.id, actor.org_id, project]) assert.match(id, /^[0-9a-f-]{36}$/); +assert.equal(sql(`SELECT permission_policy_version FROM organizations WHERE id='${actor.org_id}'`), '0'); +for (const table of ['person_permission_states', 'permission_change_receipts', 'project_management_assignments']) { + assert.equal(sql(`SELECT count(*) FROM ${table} WHERE org_id='${actor.org_id}'`), '0'); +} +const initialRevision = sql(`SELECT access_revision FROM organizations WHERE id='${actor.org_id}'`); +assert.match(initialRevision, /^\d+$/); +const stateId = '01960000-0000-7000-8000-000000000901'; +// Dioxus 0.7.9 appends a build-specific decimal hash to implicit endpoints. +// Read the actual server's registered string; never hard-code a build's hash. +const endpoints = [...new Set(readFileSync(process.env.HORAE_TEST_SERVER).toString('latin1').match(/\/api\/save_project_managers\d+/g))]; +assert.equal(endpoints.length, 1, 'expected one compiled project-manager endpoint'); + +(async () => { + const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined }); + const page = await browser.newPage({ viewport: { width: 1440, height: 900 } }); + page.setDefaultTimeout(15_000); + page.setDefaultNavigationTimeout(30_000); + const errors = []; + let releaseRead; + page.on('pageerror', error => errors.push(error.stack || error.message)); + const history = page.getByRole('table', { name: 'Permission change history', exact: true }); + const receiptIds = () => history.locator('details p').filter({ hasText: /^Receipt: / }).allTextContents(); + try { + await page.goto(`${base}/auth/login`); + await page.getByRole('button', { name: 'Sign in as Admin', exact: true }).click(); + await page.waitForURL(`${base}/`); + sql(`BEGIN; + INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source) + VALUES ('${stateId}', '${actor.org_id}', '${actor.id}', 1, + ARRAY['time_read_own','time_write_own','expense_read_own','expense_write_own','project_read_all','project_write_all'], true, 'individual'); + UPDATE organizations SET permission_policy_version=1 WHERE id='${actor.org_id}'; COMMIT;`); + await page.goto(`${base}/admin/audit`); + await expect(page.getByRole('status')).toContainText('No permission events recorded yet'); + await expect(page.locator('#audit-older')).toBeDisabled(); + + let revision = Number(initialRevision); + assert.ok(Number.isSafeInteger(revision)); + // Real session-bound commands produce 27 changes and one no-op receipt. + // No receipt JSON is manufactured or inserted by the fixture. + for (let index = 0; index < 28; index++) { + const changed = index < 27; + const response = await page.context().request.post(`${base}${endpoints[0]}`, { + data: { + expected_requester: { org_id: actor.org_id, user_id: actor.id }, + command: { + kind: 'replace_project_managers', + request_id: `01960000-0000-7000-8000-${String(1000 + index).padStart(12, '0')}`, + expected_access_revision: revision, + project_id: project, + manager_ids: index % 2 === 0 || !changed ? [actor.id] : [], + }, + }, + }); + assert.equal(response.status(), 200, await response.text()); + const outcome = await response.json(); + assert.equal(outcome.changed, changed); + assert.equal(outcome.access_revision, revision + Number(changed)); + revision = outcome.access_revision; + } + const ids = sql(`SELECT id FROM permission_change_receipts WHERE org_id='${actor.org_id}' ORDER BY created_at DESC, id DESC`).split('\n'); + assert.equal(ids.length, 28); + await page.locator('#audit-refresh').click(); + await expect(history.locator('tbody tr')).toHaveCount(25); + assert.deepEqual(await receiptIds(), ids.slice(0, 25).map(id => `Receipt: ${id}`)); + await expect(history.locator('summary').first()).toHaveText('No project manager change'); + const summary = history.locator('summary').nth(1); + await summary.focus(); + await page.keyboard.press('Enter'); + await expect(history.locator('details').nth(1)).toHaveAttribute('open', ''); + await expect(history.getByText(`Project: ${project}`, { exact: true }).nth(1)).toBeVisible(); + await expect(history.locator('details').nth(1)).toContainText(`Added manager: ${actor.id}`); + + for (const [width, height, theme] of [[1440, 900, 'dark'], [390, 844, 'light']]) { + await page.setViewportSize({ width, height }); + await page.evaluate(theme => document.documentElement.dataset.theme = theme, theme); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, 'history must not widen the viewport'); + for (const id of ['audit-refresh', 'audit-newest', 'audit-older']) { + const box = await page.locator(`#${id}`).boundingBox(); + assert.ok(box && box.x >= 0 && box.x + box.width <= width + 1, `${id} must remain within the viewport`); + } + if (process.env.HORAE_BROWSER_ARTIFACTS) { + mkdirSync(process.env.HORAE_BROWSER_ARTIFACTS, { recursive: true }); + await page.screenshot({ path: join(process.env.HORAE_BROWSER_ARTIFACTS, `permission-history-${width}-${theme}.png`), animations: 'disabled' }); + } + } + await page.setViewportSize({ width: 1440, height: 900 }); + await page.locator('#audit-older').focus(); + await page.keyboard.press('Enter'); + await expect(history.locator('tbody tr')).toHaveCount(3); + assert.deepEqual(await receiptIds(), ids.slice(25).map(id => `Receipt: ${id}`)); + await expect(page.locator('#audit-older')).toBeDisabled(); + await page.locator('#audit-newest').click(); + await expect(history.locator('tbody tr')).toHaveCount(25); + + let intercepted = false; + const heldRead = new Promise(resolve => { releaseRead = resolve; }); + const holdRead = async route => { intercepted = true; await heldRead; await route.continue(); }; + await page.route('**/api/list_permission_audit*', holdRead); + try { + await page.locator('#audit-refresh').click(); + await expect.poll(() => intercepted).toBe(true); + await expect(page.getByRole('status')).toContainText('Loading permission history'); + await expect(history).not.toBeVisible(); + for (const id of ['audit-refresh', 'audit-newest', 'audit-older']) await expect(page.locator(`#${id}`)).toBeDisabled(); + } finally { + releaseRead(); + await page.unroute('**/api/list_permission_audit*', holdRead); + } + await expect(history.locator('tbody tr')).toHaveCount(25); + sql(`UPDATE person_permission_states SET is_administrator=false WHERE id='${stateId}'`); + await page.locator('#audit-refresh').click(); + await expect(page.getByRole('alert')).toContainText('Administrator access is required'); + await expect(history).not.toBeVisible(); + sql(`UPDATE person_permission_states SET is_administrator=true WHERE id='${stateId}'`); + await page.locator('#audit-refresh').click(); + await expect(history.locator('tbody tr')).toHaveCount(25); + assert.deepEqual(await receiptIds(), ids.slice(0, 25).map(id => `Receipt: ${id}`)); + + sql(`UPDATE users SET org_role='member' WHERE id='${actor.id}'`); + await page.goto(`${base}/settings`); + await page.getByRole('link', { name: 'View permission audit log', exact: true }).click(); + await page.waitForURL(`${base}/admin/audit`); + await expect(history.locator('tbody tr')).toHaveCount(25); + sql(`UPDATE users SET active=false WHERE id='${actor.id}'`); + await page.locator('#audit-refresh').click(); + await expect(page.getByRole('alert')).toContainText('Sign in again'); + await expect(history).not.toBeVisible(); + assert.deepEqual(errors, []); + console.log(`PASS: permission history in Chromium ${browser.version()}: real writer receipts, empty/no-op states, exact paging, keyboard details, responsive themes, stale suppression, revocation, canonical admin navigation and deactivation`); + } finally { + releaseRead?.(); + await browser.close(); + sql(`BEGIN; + UPDATE users SET active=true, org_role='admin' WHERE id='${actor.id}'; + DELETE FROM project_management_assignments WHERE org_id='${actor.org_id}' AND manager_id='${actor.id}' AND project_id='${project}'; + DELETE FROM permission_change_receipts WHERE org_id='${actor.org_id}' AND actor_user_id='${actor.id}' AND request_id::text LIKE '01960000-0000-7000-8000-%'; + DELETE FROM person_permission_states WHERE id='${stateId}' AND org_id='${actor.org_id}'; + UPDATE organizations SET permission_policy_version=0, access_revision=${initialRevision} WHERE id='${actor.org_id}'; COMMIT;`); + } +})().catch(error => { console.error(error); process.exitCode = 1; }); diff --git a/crates/horae/tests/browser/run-design-checks.sh b/crates/horae/tests/browser/run-design-checks.sh index 60be43f8..43fd8eb0 100644 --- a/crates/horae/tests/browser/run-design-checks.sh +++ b/crates/horae/tests/browser/run-design-checks.sh @@ -65,7 +65,7 @@ fi # Explicit filenames allow focused iteration without bypassing database isolation. suites=("$@") if [[ ${#suites[@]} == 0 ]]; then - suites=(projects-design responsive-layout menu-popovers mobile-navigation project-bulk-actions project-bulk-recovery action-errors new-project invoice-preparation project-task-rates new-project-permissions new-project-task-errors new-project-keyboard new-project-transport project-edit new-project-navigation clients client-context clients-access clients-errors clients-visual own-permissions) + suites=(projects-design responsive-layout menu-popovers mobile-navigation project-bulk-actions project-bulk-recovery action-errors new-project invoice-preparation project-task-rates new-project-permissions new-project-task-errors new-project-keyboard new-project-transport project-edit new-project-navigation clients client-context clients-access clients-errors clients-visual own-permissions permission-history) fi for suite in "${suites[@]}"; do if [[ ! $suite =~ ^[a-z][a-z-]*$ || ! -f "$browser_tests/$suite.cjs" ]]; then From 04dd6c442cedb4ea2d2b8a0d6d6cb5c2fe967f28 Mon Sep 17 00:00:00 2001 From: Aldo Borrero <82811+aldoborrero@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:07:11 +0200 Subject: [PATCH 4/6] Include project grant prerequisites in browser fixture --- crates/horae/tests/browser/permission-history.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horae/tests/browser/permission-history.cjs b/crates/horae/tests/browser/permission-history.cjs index 9b4fd2db..e1fa0154 100644 --- a/crates/horae/tests/browser/permission-history.cjs +++ b/crates/horae/tests/browser/permission-history.cjs @@ -43,7 +43,7 @@ assert.equal(endpoints.length, 1, 'expected one compiled project-manager endpoin sql(`BEGIN; INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source) VALUES ('${stateId}', '${actor.org_id}', '${actor.id}', 1, - ARRAY['time_read_own','time_write_own','expense_read_own','expense_write_own','project_read_all','project_write_all'], true, 'individual'); + ARRAY['time_read_own','time_write_own','expense_read_own','expense_write_own','project_read_managed','project_write_managed','project_read_all','project_write_all'], true, 'individual'); UPDATE organizations SET permission_policy_version=1 WHERE id='${actor.org_id}'; COMMIT;`); await page.goto(`${base}/admin/audit`); await expect(page.getByRole('status')).toContainText('No permission events recorded yet'); From 1a8103343ddbd2c8914a8797e94ef9103741d214 Mon Sep 17 00:00:00 2001 From: Aldo Borrero <82811+aldoborrero@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:10:56 +0200 Subject: [PATCH 5/6] Await released history response before removing interception --- crates/horae/tests/browser/permission-history.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/horae/tests/browser/permission-history.cjs b/crates/horae/tests/browser/permission-history.cjs index e1fa0154..8145d981 100644 --- a/crates/horae/tests/browser/permission-history.cjs +++ b/crates/horae/tests/browser/permission-history.cjs @@ -120,9 +120,9 @@ assert.equal(endpoints.length, 1, 'expected one compiled project-manager endpoin for (const id of ['audit-refresh', 'audit-newest', 'audit-older']) await expect(page.locator(`#${id}`)).toBeDisabled(); } finally { releaseRead(); - await page.unroute('**/api/list_permission_audit*', holdRead); } await expect(history.locator('tbody tr')).toHaveCount(25); + await page.unroute('**/api/list_permission_audit*', holdRead); sql(`UPDATE person_permission_states SET is_administrator=false WHERE id='${stateId}'`); await page.locator('#audit-refresh').click(); await expect(page.getByRole('alert')).toContainText('Administrator access is required'); From 487042cb6a9e51423ea992912e7c6a89f65bd36e Mon Sep 17 00:00:00 2001 From: Aldo Borrero <82811+aldoborrero@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:56:02 +0200 Subject: [PATCH 6/6] Bind history browser fixtures to the signed-in administrator --- .../tests/browser/permission-history.cjs | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/horae/tests/browser/permission-history.cjs b/crates/horae/tests/browser/permission-history.cjs index 8145d981..259d2610 100644 --- a/crates/horae/tests/browser/permission-history.cjs +++ b/crates/horae/tests/browser/permission-history.cjs @@ -11,20 +11,14 @@ assert.ok(['localhost', '127.0.0.1'].includes(target.hostname) && target.port == assert.equal(database.pathname, '/horae'); assert.match(database.searchParams.get('host') || '', /^\/tmp\/horae-browser\.[A-Za-z0-9]+$/); const sql = query => execFileSync('psql', [process.env.DATABASE_URL, '-X', '-v', 'ON_ERROR_STOP=1', '-qAt', '-c', query], { encoding: 'utf8' }).trim(); -const actor = JSON.parse(sql("SELECT row_to_json(u) FROM (SELECT id, org_id FROM users WHERE email='admin@example.com' AND active AND org_role='admin') u")); -const project = sql(`SELECT id FROM projects WHERE org_id='${actor.org_id}' AND active ORDER BY id LIMIT 1`); -for (const id of [actor.id, actor.org_id, project]) assert.match(id, /^[0-9a-f-]{36}$/); -assert.equal(sql(`SELECT permission_policy_version FROM organizations WHERE id='${actor.org_id}'`), '0'); -for (const table of ['person_permission_states', 'permission_change_receipts', 'project_management_assignments']) { - assert.equal(sql(`SELECT count(*) FROM ${table} WHERE org_id='${actor.org_id}'`), '0'); -} -const initialRevision = sql(`SELECT access_revision FROM organizations WHERE id='${actor.org_id}'`); -assert.match(initialRevision, /^\d+$/); const stateId = '01960000-0000-7000-8000-000000000901'; // Dioxus 0.7.9 appends a build-specific decimal hash to implicit endpoints. // Read the actual server's registered string; never hard-code a build's hash. -const endpoints = [...new Set(readFileSync(process.env.HORAE_TEST_SERVER).toString('latin1').match(/\/api\/save_project_managers\d+/g))]; +const serverStrings = readFileSync(process.env.HORAE_TEST_SERVER).toString('latin1'); +const endpoints = [...new Set(serverStrings.match(/\/api\/save_project_managers\d+/g))]; assert.equal(endpoints.length, 1, 'expected one compiled project-manager endpoint'); +const identityEndpoints = [...new Set(serverStrings.match(/\/api\/get_me\d+/g))]; +assert.equal(identityEndpoints.length, 1, 'expected one compiled session-identity endpoint'); (async () => { const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH || undefined }); @@ -33,6 +27,7 @@ assert.equal(endpoints.length, 1, 'expected one compiled project-manager endpoin page.setDefaultNavigationTimeout(30_000); const errors = []; let releaseRead; + let actor, project, initialRevision, fixtureStarted = false; page.on('pageerror', error => errors.push(error.stack || error.message)); const history = page.getByRole('table', { name: 'Permission change history', exact: true }); const receiptIds = () => history.locator('details p').filter({ hasText: /^Receipt: / }).allTextContents(); @@ -40,11 +35,27 @@ assert.equal(endpoints.length, 1, 'expected one compiled project-manager endpoin await page.goto(`${base}/auth/login`); await page.getByRole('button', { name: 'Sign in as Admin', exact: true }).click(); await page.waitForURL(`${base}/`); + // A preceding suite may create another admin selected by dev login. + const identity = await page.context().request.post(`${base}${identityEndpoints[0]}`, { data: {} }); + assert.equal(identity.status(), 200, await identity.text()); + actor = await identity.json(); + for (const id of [actor.id, actor.org_id]) assert.match(id, /^[0-9a-f-]{36}$/); + assert.equal(actor.org_role, 'admin'); + assert.equal(sql(`SELECT active FROM users WHERE id='${actor.id}' AND org_id='${actor.org_id}'`), 't'); + project = sql(`SELECT id FROM projects WHERE org_id='${actor.org_id}' AND active ORDER BY id LIMIT 1`); + assert.match(project, /^[0-9a-f-]{36}$/); + assert.equal(sql(`SELECT permission_policy_version FROM organizations WHERE id='${actor.org_id}'`), '0'); + for (const table of ['person_permission_states', 'permission_change_receipts', 'project_management_assignments']) { + assert.equal(sql(`SELECT count(*) FROM ${table} WHERE org_id='${actor.org_id}'`), '0'); + } + initialRevision = sql(`SELECT access_revision FROM organizations WHERE id='${actor.org_id}'`); + assert.match(initialRevision, /^\d+$/); sql(`BEGIN; INSERT INTO person_permission_states (id, org_id, user_id, catalog_version, grants, is_administrator, source) VALUES ('${stateId}', '${actor.org_id}', '${actor.id}', 1, ARRAY['time_read_own','time_write_own','expense_read_own','expense_write_own','project_read_managed','project_write_managed','project_read_all','project_write_all'], true, 'individual'); UPDATE organizations SET permission_policy_version=1 WHERE id='${actor.org_id}'; COMMIT;`); + fixtureStarted = true; await page.goto(`${base}/admin/audit`); await expect(page.getByRole('status')).toContainText('No permission events recorded yet'); await expect(page.locator('#audit-older')).toBeDisabled(); @@ -146,7 +157,7 @@ assert.equal(endpoints.length, 1, 'expected one compiled project-manager endpoin } finally { releaseRead?.(); await browser.close(); - sql(`BEGIN; + if (fixtureStarted) sql(`BEGIN; UPDATE users SET active=true, org_role='admin' WHERE id='${actor.id}'; DELETE FROM project_management_assignments WHERE org_id='${actor.org_id}' AND manager_id='${actor.id}' AND project_id='${project}'; DELETE FROM permission_change_receipts WHERE org_id='${actor.org_id}' AND actor_user_id='${actor.id}' AND request_id::text LIKE '01960000-0000-7000-8000-%';