Skip to content

Commit 08a00ef

Browse files
Disconnect clients when updating RLS rules (#3574)
# Description of Changes Disconnects clients when adding, removing, or updating RLS rules. Client are not disconnected if an auto-migration does not touch any RLS rules. > Why do we need to disconnect clients at all? Subscriptions cache RLS rules. By disconnecting clients, we forcibly evict stale invalid RLS rules from the cache. # API and ABI breaking changes Bug fix. Previously subscriptions would keep stale RLS rules in cache. # Expected complexity level and risk 1 # Testing - [x] Adding unit and smoke tests - [x] Manual inspection with `spacetime subscribe` --------- Signed-off-by: joshua-spacetime <josh@clockworklabs.io> Co-authored-by: joshua-spacetime <josh@clockworklabs.io>
1 parent 0de8910 commit 08a00ef

File tree

4 files changed

+146
-29
lines changed

4 files changed

+146
-29
lines changed

crates/schema/src/auto_migrate.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1028,13 +1028,24 @@ fn auto_migrate_constraints(plan: &mut AutoMigratePlan, new_tables: &HashSet<&Id
10281028
// Because we can refer to many tables and fields on the row level-security query, we need to remove all of them,
10291029
// then add the new ones, instead of trying to track the graph of dependencies.
10301030
fn auto_migrate_row_level_security(plan: &mut AutoMigratePlan) -> Result<()> {
1031+
// Track if any RLS rules were changed.
1032+
let mut old_rls = HashSet::new();
1033+
let mut new_rls = HashSet::new();
1034+
10311035
for rls in plan.old.row_level_security() {
1036+
old_rls.insert(rls.key());
10321037
plan.steps.push(AutoMigrateStep::RemoveRowLevelSecurity(rls.key()));
10331038
}
10341039
for rls in plan.new.row_level_security() {
1040+
new_rls.insert(rls.key());
10351041
plan.steps.push(AutoMigrateStep::AddRowLevelSecurity(rls.key()));
10361042
}
10371043

1044+
// We can force flush the cache by force disconnecting all clients if an RLS rule has been added, removed, or updated.
1045+
if old_rls != new_rls && !plan.disconnects_all_users() {
1046+
plan.steps.push(AutoMigrateStep::DisconnectAllUsers);
1047+
}
1048+
10381049
Ok(())
10391050
}
10401051

@@ -2293,4 +2304,52 @@ mod tests {
22932304
);
22942305
}
22952306
}
2307+
2308+
#[test]
2309+
fn change_rls_disconnect_clients() {
2310+
let old_def = create_module_def(|_builder| {});
2311+
2312+
let new_def = create_module_def(|_builder| {});
2313+
2314+
let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed");
2315+
assert!(!plan.disconnects_all_users(), "{plan:#?}");
2316+
2317+
let old_def = create_module_def(|builder| {
2318+
builder.add_row_level_security("SELECT true;");
2319+
});
2320+
let new_def = create_module_def(|builder| {
2321+
builder.add_row_level_security("SELECT false;");
2322+
});
2323+
2324+
let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed");
2325+
assert!(plan.disconnects_all_users(), "{plan:#?}");
2326+
2327+
let old_def = create_module_def(|builder| {
2328+
builder.add_row_level_security("SELECT true;");
2329+
});
2330+
2331+
let new_def = create_module_def(|_builder| {
2332+
// Remove RLS
2333+
});
2334+
let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed");
2335+
assert!(plan.disconnects_all_users(), "{plan:#?}");
2336+
2337+
let old_def = create_module_def(|_builder| {});
2338+
2339+
let new_def = create_module_def(|builder| {
2340+
builder.add_row_level_security("SELECT false;");
2341+
});
2342+
let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed");
2343+
assert!(plan.disconnects_all_users(), "{plan:#?}");
2344+
2345+
let old_def = create_module_def(|builder| {
2346+
builder.add_row_level_security("SELECT true;");
2347+
});
2348+
2349+
let new_def = create_module_def(|builder| {
2350+
builder.add_row_level_security("SELECT true;");
2351+
});
2352+
let plan = ponder_auto_migrate(&old_def, &new_def).expect("auto migration should succeed");
2353+
assert!(!plan.disconnects_all_users(), "{plan:#?}");
2354+
}
22962355
}

crates/schema/src/snapshots/spacetimedb_schema__auto_migrate__tests__empty_to_populated_migration.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,4 @@ expression: "plan.pretty_print(PrettyPrintStyle::AnsiColor).expect(\"should pret
6161

6262
▸ Created row level security policy:
6363
`SELECT * FROM Apples`
64+
!!! Warning: All clients will be disconnected due to breaking schema changes

smoketests/tests/auto_migration.py

Lines changed: 1 addition & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@ class AddTableAutoMigration(Smoketest):
4040
x: f64,
4141
y: f64,
4242
}
43-
44-
#[spacetimedb::client_visibility_filter]
45-
const PERSON_VISIBLE: spacetimedb::Filter = spacetimedb::Filter::Sql("SELECT * FROM person");
4643
"""
4744

4845
MODULE_CODE = MODULE_CODE_INIT + """
@@ -100,9 +97,6 @@ class AddTableAutoMigration(Smoketest):
10097
log::info!("{}: {}", prefix, book.isbn);
10198
}
10299
}
103-
104-
#[spacetimedb::client_visibility_filter]
105-
const BOOK_VISIBLE: spacetimedb::Filter = spacetimedb::Filter::Sql("SELECT * FROM book");
106100
"""
107101
)
108102

@@ -115,17 +109,6 @@ def assertSql(self, sql, expected):
115109

116110
def test_add_table_auto_migration(self):
117111
"""This tests uploading a module with a schema change that should not require clearing the database."""
118-
119-
# Check the row-level SQL filter is created correctly
120-
self.assertSql(
121-
"SELECT sql FROM st_row_level_security",
122-
"""\
123-
sql
124-
------------------------
125-
"SELECT * FROM person"
126-
""",
127-
)
128-
129112
logging.info("Initial publish complete")
130113

131114
# Start a subscription before publishing the module, to test that the subscription remains intact after re-publishing.
@@ -154,18 +137,7 @@ def test_add_table_auto_migration(self):
154137
# If subscription, we should get 4 rows corresponding to 4 reducer calls (including before and after update)
155138
sub = sub();
156139
self.assertEqual(len(sub), 4)
157-
158-
# Check the row-level SQL filter is added correctly
159-
self.assertSql(
160-
"SELECT sql FROM st_row_level_security",
161-
"""\
162-
sql
163-
------------------------
164-
"SELECT * FROM person"
165-
"SELECT * FROM book"
166-
""",
167-
)
168-
140+
169141
self.logs(100)
170142

171143
self.call("add_person", "Husserl", "Professor")

smoketests/tests/rls.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import logging
2+
13
from .. import Smoketest, random_string
24

35
class Rls(Smoketest):
@@ -78,3 +80,86 @@ def test_publish_fails_for_rls_on_private_table(self):
7880

7981
with self.assertRaises(Exception):
8082
self.publish_module(name)
83+
84+
class DisconnectRls(Smoketest):
85+
AUTOPUBLISH = False
86+
87+
MODULE_CODE = """
88+
use spacetimedb::{Identity, ReducerContext, Table};
89+
90+
#[spacetimedb::table(name = users, public)]
91+
pub struct Users {
92+
name: String,
93+
identity: Identity,
94+
}
95+
96+
#[spacetimedb::reducer]
97+
pub fn add_user(ctx: &ReducerContext, name: String) {
98+
ctx.db.users().insert(Users { name, identity: ctx.sender });
99+
}
100+
"""
101+
102+
ADD_RLS = """
103+
#[spacetimedb::client_visibility_filter]
104+
const USER_FILTER: spacetimedb::Filter = spacetimedb::Filter::Sql(
105+
"SELECT * FROM users WHERE identity = :sender"
106+
);
107+
"""
108+
109+
def assertSql(self, sql, expected):
110+
self.maxDiff = None
111+
sql_out = self.spacetime("sql", self.database_identity, sql)
112+
sql_out = "\n".join([line.rstrip() for line in sql_out.splitlines()])
113+
expected = "\n".join([line.rstrip() for line in expected.splitlines()])
114+
self.assertMultiLineEqual(sql_out, expected)
115+
116+
def test_rls_disconnect_if_change(self):
117+
"""This tests that changing the RLS rules disconnects existing clients"""
118+
119+
name = random_string()
120+
121+
self.write_module_code(self.MODULE_CODE)
122+
123+
self.publish_module(name)
124+
logging.info("Initial publish complete")
125+
126+
# Now add the RLS rules
127+
self.write_module_code(self.MODULE_CODE + self.ADD_RLS)
128+
self.publish_module(name, clear=False, break_clients=True)
129+
130+
# Check the row-level SQL filter is added correctly
131+
self.assertSql(
132+
"SELECT sql FROM st_row_level_security",
133+
"""\
134+
sql
135+
------------------------------------------------
136+
"SELECT * FROM users WHERE identity = :sender"
137+
""",
138+
)
139+
140+
logging.info("Re-publish with RLS complete")
141+
142+
logs = self.logs(100)
143+
144+
# Validate disconnect + schema migration logs
145+
self.assertIn("Disconnecting all users", logs)
146+
147+
def test_rls_no_disconnect(self):
148+
"""This tests that not changing the RLS rules does not disconnect existing clients"""
149+
150+
name = random_string()
151+
152+
self.write_module_code(self.MODULE_CODE + self.ADD_RLS)
153+
154+
self.publish_module(name)
155+
logging.info("Initial publish complete")
156+
157+
# Now re-publish the same module code
158+
self.publish_module(name, clear=False, break_clients=False)
159+
160+
logging.info("Re-publish without RLS change complete")
161+
162+
logs = self.logs(100)
163+
164+
# Validate no disconnect logs
165+
self.assertNotIn("Disconnecting all users", logs)

0 commit comments

Comments
 (0)