Skip to content

Commit c9f0ce4

Browse files
Trung Nguyencursoragent
andcommitted
fix: refuse unaudited denies, restore last connection, and colour environments
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a3a6b58 commit c9f0ce4

12 files changed

Lines changed: 239 additions & 30 deletions

File tree

PLAN.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -532,12 +532,16 @@ Removed material included retired application source, tests, project files, runt
532532

533533
In progress since 2026-08-21. Slice 10.1 is implemented and locally
534534
release-verified: activation is additive, each window owns and releases its own
535-
connection, and the single-connection limit is gone. Slice 10.2 is implemented:
536-
groups, tags, favourites, search, and URL import. Welcome rows still do not
537-
show an environment colour. Slice 10.3 has a capability-declared TSV activity
538-
dialog whose in-flight query is cancelled when the dialog closes. The typed
539-
sessions console and blocking trees are not started. Structure edit tabs now
540-
restore with the rest of the workspace. Slices 10.4 through 10.7 are not started.
535+
connection, and the single-connection limit is gone. Startup reopens the last
536+
connection this window used; a failed reconnect leaves the welcome page up and
537+
does not attach those tabs to another connection. Restoring every connection
538+
that still has saved tabs, including other windows, is not started. Slice 10.2
539+
is implemented: groups, tags, favourites, search, URL import, and an
540+
environment colour on each saved-connection row. Slice 10.3 has a
541+
capability-declared TSV activity dialog whose in-flight query is cancelled when
542+
the dialog closes. The typed sessions console and blocking trees are not
543+
started. Structure edit tabs now restore with the rest of the workspace.
544+
Slices 10.4 through 10.7 are not started.
541545

542546
The product is strong on safety and thin on operations. A DBA who manages many
543547
servers gets one active connection per process, an activity dialog that renders

linux/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@
8080

8181
### Fixed
8282

83+
- A denied statement or dismissed approval is refused if that denial cannot be written to the audit journal
84+
- The last open connection is reopened when the app starts. If it cannot connect, its tabs stay with that connection instead of attaching to another database
85+
- Saved connection rows show the environment as a colour
8386
- Measuring how many rows an UPDATE or DELETE would touch now uses the same timeout and cancellation as the write itself, instead of running an unbounded count first
8487
- Reading a table's indexes and foreign keys now stops at the query timeout, and a failed read no longer pretends the table has none
8588
- Structure tabs reopen after a reconnect, and the saved workspace no longer points at the wrong tab when a draft was skipped

linux/ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ Phase 5 documentation is current as of 2026-08-18. Keeping it current is a stand
125125
- [ ] SQL file open/save with external-change detection
126126
- [ ] PostgreSQL objects, users/roles, and administration
127127
- [ ] Import/export and backup/restore
128-
- [x] Connection groups, tags, favourites, search, and URL import (Phase 10.2)
128+
- [x] Connection groups, tags, favourites, search, URL import, and environment colour (Phase 10.2)
129129
- [ ] Reusable SSH and transport profiles
130130
- [ ] True result streaming and optional Parquet export
131131

@@ -152,7 +152,7 @@ The repository extraction completed on 2026-08-17. Product planning now follows
152152
### 10: DBA operations at scale
153153

154154
- [x] Several connections open at once, with fail-closed per-tab ownership across all of them
155-
- [x] Connection groups, tags, favorites, search, and URL import
155+
- [x] Connection groups, tags, favorites, search, URL import, and environment colour on each row
156156
- [ ] A typed sessions and locks console with capability-declared driver support and governed session termination
157157
- [ ] A PostgreSQL server health panel that degrades cleanly when a statistics extension is absent
158158
- [ ] Configurable pool size and timeouts per saved connection, honoured by the driver
Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,21 @@
1+
use std::sync::Mutex;
2+
use std::sync::atomic::{AtomicBool, Ordering};
3+
14
use serde::{Deserialize, Serialize};
5+
use uuid::Uuid;
26

37
use super::config_io::{atomic_write_json, xdg_config_path};
48

9+
static FILE_LOCK: Mutex<()> = Mutex::new(());
10+
static SESSION_RESTORE_ATTEMPTED: AtomicBool = AtomicBool::new(false);
11+
512
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
613
pub struct WindowState {
714
pub width: i32,
815
pub height: i32,
916
pub maximized: bool,
17+
#[serde(default)]
18+
pub last_connection_id: Option<Uuid>,
1019
}
1120

1221
impl Default for WindowState {
@@ -15,11 +24,12 @@ impl Default for WindowState {
1524
width: 1200,
1625
height: 760,
1726
maximized: false,
27+
last_connection_id: None,
1828
}
1929
}
2030
}
2131

22-
pub fn load() -> WindowState {
32+
fn load_locked() -> WindowState {
2333
let Some(path) = xdg_config_path("window.json") else {
2434
return WindowState::default();
2535
};
@@ -29,11 +39,78 @@ pub fn load() -> WindowState {
2939
.unwrap_or_default()
3040
}
3141

32-
pub fn save(state: WindowState) {
42+
fn save_locked(state: &WindowState) {
3343
let Some(path) = xdg_config_path("window.json") else {
3444
return;
3545
};
36-
if let Err(e) = atomic_write_json(&path, &state) {
46+
if let Err(e) = atomic_write_json(&path, state) {
3747
tracing::warn!(path = %path.display(), error = %e, "window_state: write failed");
3848
}
3949
}
50+
51+
pub fn load() -> WindowState {
52+
let _guard = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
53+
load_locked()
54+
}
55+
56+
pub fn save_geometry(width: i32, height: i32, maximized: bool) {
57+
let _guard = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
58+
let mut state = load_locked();
59+
state.width = width;
60+
state.height = height;
61+
state.maximized = maximized;
62+
save_locked(&state);
63+
}
64+
65+
pub fn set_last_connection_id(id: Option<Uuid>) {
66+
let _guard = FILE_LOCK.lock().unwrap_or_else(|e| e.into_inner());
67+
let mut state = load_locked();
68+
state.last_connection_id = id;
69+
save_locked(&state);
70+
}
71+
72+
pub fn take_session_restore_turn() -> bool {
73+
!SESSION_RESTORE_ATTEMPTED.swap(true, Ordering::SeqCst)
74+
}
75+
76+
pub fn connection_id_to_restore(last_connection_id: Option<Uuid>, available: &[Uuid]) -> Option<Uuid> {
77+
let id = last_connection_id?;
78+
available.contains(&id).then_some(id)
79+
}
80+
81+
#[cfg(test)]
82+
mod tests {
83+
use super::*;
84+
85+
#[test]
86+
fn a_missing_last_connection_id_deserializes_as_none() {
87+
let parsed: WindowState =
88+
serde_json::from_str(r#"{"width":800,"height":600,"maximized":false}"#).expect("legacy window.json");
89+
assert_eq!(parsed.last_connection_id, None);
90+
assert_eq!(parsed.width, 800);
91+
}
92+
93+
#[test]
94+
fn last_connection_id_round_trips() {
95+
let id = Uuid::new_v4();
96+
let state = WindowState {
97+
width: 1,
98+
height: 2,
99+
maximized: true,
100+
last_connection_id: Some(id),
101+
};
102+
let parsed: WindowState =
103+
serde_json::from_slice(&serde_json::to_vec(&state).expect("serialize")).expect("deserialize");
104+
assert_eq!(parsed.last_connection_id, Some(id));
105+
assert!(parsed.maximized);
106+
}
107+
108+
#[test]
109+
fn restore_skips_an_unknown_or_absent_connection() {
110+
let id = Uuid::new_v4();
111+
assert_eq!(connection_id_to_restore(None, &[id]), None);
112+
assert_eq!(connection_id_to_restore(Some(id), &[]), None);
113+
assert_eq!(connection_id_to_restore(Some(Uuid::new_v4()), &[id]), None);
114+
assert_eq!(connection_id_to_restore(Some(id), &[id]), Some(id));
115+
}
116+
}

linux/crates/app/src/ui/app/connection.rs

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ impl App {
7272
// Restore tabs (browse + editor) persisted from the prior session
7373
// for this connection.
7474
if let Some(connection_id) = self.connection_id {
75+
crate::services::window_state::set_last_connection_id(Some(connection_id));
7576
self.restore_workspace_tabs(connection_id, sender.clone());
7677
// Stamp `last_opened_at = now()` then reload connections so
7778
// the popover + welcome view re-sort with the fresh
@@ -148,6 +149,9 @@ impl App {
148149
// different connection would target a non-existent table.
149150
self.clear_closed_tabs_stack();
150151
if let Some(id) = self.connection_id.take() {
152+
if crate::services::window_state::load().last_connection_id == Some(id) {
153+
crate::services::window_state::set_last_connection_id(None);
154+
}
151155
database_service::instance().close(id);
152156
}
153157
self.schema_buffer.set_text(crate::ui::editor::SQL_KEYWORDS);
@@ -197,8 +201,24 @@ impl App {
197201
));
198202
self.prune_connection_organization(sender.clone());
199203
if !self.connected {
200-
self.show_welcome_page(sender);
204+
self.show_welcome_page(sender.clone());
205+
self.restore_last_connection(connections, sender);
206+
}
207+
}
208+
209+
fn restore_last_connection(&mut self, connections: &[SavedConnection], sender: ComponentSender<Self>) {
210+
if !crate::services::window_state::take_session_restore_turn() {
211+
return;
201212
}
213+
let last = crate::services::window_state::load().last_connection_id;
214+
let available: Vec<Uuid> = connections.iter().map(|saved| saved.id).collect();
215+
let Some(id) = crate::services::window_state::connection_id_to_restore(last, &available) else {
216+
return;
217+
};
218+
let Some(saved) = connections.iter().find(|saved| saved.id == id).cloned() else {
219+
return;
220+
};
221+
self.on_open_saved(saved, sender);
202222
}
203223

204224
pub(super) fn on_poll_health(&mut self) {
@@ -538,6 +558,9 @@ fn execute_delete_connection(id: Uuid, sender: ComponentSender<App>) {
538558
let _ = tablepro_storage::delete_password(id).await;
539559
let _ = tablepro_storage::delete_ssh_password(id).await;
540560
let _ = tablepro_storage::delete_ssh_passphrase(id).await;
561+
if crate::services::window_state::load().last_connection_id == Some(id) {
562+
crate::services::window_state::set_last_connection_id(None);
563+
}
541564
sender_clone.input(AppMsg::ReloadConnections);
542565
})
543566
.drop_on_shutdown()

linux/crates/app/src/ui/app/init_css.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,17 @@ pub(super) fn install_pending_change_css() {
5353
}\
5454
.tp-row-leftmost-error-flash {\
5555
animation: tp-flash-error 1.8s ease-out;\
56-
}",
56+
}\
57+
.tp-env-swatch {\
58+
min-width: 6px;\
59+
border-radius: 3px;\
60+
margin-top: 8px;\
61+
margin-bottom: 8px;\
62+
}\
63+
.tp-env-local { background-color: @success_color; }\
64+
.tp-env-dev { background-color: @accent_color; }\
65+
.tp-env-staging { background-color: @warning_color; }\
66+
.tp-env-prod { background-color: @error_color; }",
5767
);
5868
gtk::style_context_add_provider_for_display(&display, &provider, gtk::STYLE_PROVIDER_PRIORITY_APPLICATION);
5969
}

linux/crates/app/src/ui/app/init_window.rs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,7 @@ pub(super) fn install_window_lifecycle(
173173
} else {
174174
(w.width(), w.height())
175175
};
176-
crate::services::window_state::save(crate::services::window_state::WindowState {
177-
width,
178-
height,
179-
maximized: w.is_maximized(),
180-
});
176+
crate::services::window_state::save_geometry(width, height, w.is_maximized());
181177
glib::Propagation::Proceed
182178
});
183179

linux/crates/app/src/ui/connection_row.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ use relm4::factory::{DynamicIndex, FactoryComponent, FactorySender};
33
use relm4::{adw, gtk};
44
use uuid::Uuid;
55

6-
use tablepro_core::AuthMode;
6+
use tablepro_core::{AuthMode, Environment};
77
use tablepro_storage::{ConnectionOrganization, SavedConnection};
88

99
/// What a row needs to render: the saved record plus its organisation
@@ -61,6 +61,15 @@ impl FactoryComponent for ConnectionRow {
6161
set_activatable: true,
6262
connect_activated => ConnectionRowMsg::Open,
6363

64+
add_prefix = &gtk::Box {
65+
add_css_class: "tp-env-swatch",
66+
add_css_class: environment_css_class(self.saved.environment),
67+
set_valign: gtk::Align::Fill,
68+
set_hexpand: false,
69+
set_width_request: 6,
70+
set_tooltip_text: Some(self.saved.environment.display_name()),
71+
},
72+
6473
add_prefix = &gtk::Button {
6574
set_icon_name: if self.organization.favorite {
6675
"starred-symbolic"
@@ -169,6 +178,15 @@ impl FactoryComponent for ConnectionRow {
169178
}
170179
}
171180

181+
pub(crate) fn environment_css_class(environment: Environment) -> &'static str {
182+
match environment {
183+
Environment::Local => "tp-env-local",
184+
Environment::Dev => "tp-env-dev",
185+
Environment::Staging => "tp-env-staging",
186+
Environment::Prod => "tp-env-prod",
187+
}
188+
}
189+
172190
fn subtitle_for(saved: &SavedConnection, organization: &ConnectionOrganization) -> String {
173191
let mut subtitle = endpoint_for(saved);
174192
if let Some(group) = organization.group.as_deref() {
@@ -260,4 +278,12 @@ mod tests {
260278
endpoint_for(&saved("sa", AuthMode::Password))
261279
);
262280
}
281+
282+
#[test]
283+
fn each_environment_has_its_own_colour_class() {
284+
assert_eq!(environment_css_class(Environment::Local), "tp-env-local");
285+
assert_eq!(environment_css_class(Environment::Dev), "tp-env-dev");
286+
assert_eq!(environment_css_class(Environment::Staging), "tp-env-staging");
287+
assert_eq!(environment_css_class(Environment::Prod), "tp-env-prod");
288+
}
263289
}

linux/crates/policy/src/guard.rs

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -471,13 +471,9 @@ impl PolicyGuard {
471471
let Err(error) = result else {
472472
return Ok(());
473473
};
474-
if self.ctx.principal.is_agent() {
475-
return Err(DriverError::PolicyDenied(format!(
476-
"operation denied because audit recording failed: {error}"
477-
)));
478-
}
479-
tracing::warn!(error = %error, "audit outcome could not be persisted");
480-
Ok(())
474+
Err(DriverError::PolicyDenied(format!(
475+
"operation denied because audit recording failed: {error}"
476+
)))
481477
}
482478

483479
fn handle_read_audit_failure(&self, result: Result<(), AuditError>) -> Result<(), DriverError> {

linux/crates/policy/src/guard_tests_audit.rs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -432,3 +432,74 @@ async fn null_sink_denies_production_write() {
432432

433433
assert_eq!(executes.load(Ordering::SeqCst), 0);
434434
}
435+
436+
#[tokio::test]
437+
async fn human_policy_deny_fails_closed_when_outcome_cannot_be_recorded() {
438+
let executes = Arc::new(AtomicUsize::new(0));
439+
let mut ctx = context(
440+
Principal::human_gui(),
441+
Environment::Local,
442+
PolicyConfig::default(),
443+
Arc::new(AutoApproveSink),
444+
Arc::new(SequenceAuditSink::new(vec![AuditRecordPhase::Outcome])),
445+
Arc::new(AuditState::new()),
446+
);
447+
ctx.read_only = true;
448+
let guard = PolicyGuard::new(connection(executes.clone(), Arc::new(AtomicUsize::new(0))), ctx);
449+
450+
let error = guard
451+
.execute("INSERT INTO jobs(id) VALUES (1)")
452+
.await
453+
.expect_err("unaudited deny must fail closed");
454+
455+
assert!(error.to_string().contains("audit recording failed"));
456+
assert_eq!(executes.load(Ordering::SeqCst), 0);
457+
}
458+
459+
#[tokio::test]
460+
async fn human_approval_deny_fails_closed_when_outcome_cannot_be_recorded() {
461+
let executes = Arc::new(AtomicUsize::new(0));
462+
let guard = PolicyGuard::new(
463+
connection(executes.clone(), Arc::new(AtomicUsize::new(0))),
464+
context(
465+
Principal::human_gui(),
466+
Environment::Prod,
467+
PolicyConfig::default(),
468+
Arc::new(DenyApprovalSink),
469+
Arc::new(SequenceAuditSink::new(vec![AuditRecordPhase::Outcome])),
470+
Arc::new(AuditState::new()),
471+
),
472+
);
473+
474+
let error = guard
475+
.execute("INSERT INTO jobs(id) VALUES (1)")
476+
.await
477+
.expect_err("unaudited approval deny must fail closed");
478+
479+
assert!(error.to_string().contains("audit recording failed"));
480+
assert_eq!(executes.load(Ordering::SeqCst), 0);
481+
}
482+
483+
#[tokio::test]
484+
async fn human_policy_deny_keeps_the_policy_message_when_audit_succeeds() {
485+
let executes = Arc::new(AtomicUsize::new(0));
486+
let mut ctx = context(
487+
Principal::human_gui(),
488+
Environment::Local,
489+
PolicyConfig::default(),
490+
Arc::new(AutoApproveSink),
491+
Arc::new(SequenceAuditSink::new(vec![])),
492+
Arc::new(AuditState::new()),
493+
);
494+
ctx.read_only = true;
495+
let guard = PolicyGuard::new(connection(executes.clone(), Arc::new(AtomicUsize::new(0))), ctx);
496+
497+
let error = guard
498+
.execute("INSERT INTO jobs(id) VALUES (1)")
499+
.await
500+
.expect_err("read-only write must be denied");
501+
502+
assert!(error.to_string().contains("read-only"));
503+
assert!(!error.to_string().contains("audit recording failed"));
504+
assert_eq!(executes.load(Ordering::SeqCst), 0);
505+
}

0 commit comments

Comments
 (0)