From ebd49c155707e8c6317ff1f1ad271de25fe99bb7 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Tue, 20 Jan 2026 07:37:42 +0100 Subject: [PATCH 1/8] WIP --- crates/client-api/src/lib.rs | 17 ++++++++++++----- crates/client-api/src/routes/database.rs | 18 ++++++++++++++---- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index 1ccadc877cd..b3c6bea0dff 100644 --- a/crates/client-api/src/lib.rs +++ b/crates/client-api/src/lib.rs @@ -508,9 +508,12 @@ impl axum::response::IntoResponse for Unauthorized { } /// Action to be authorized via [Authorization::authorize_action]. -#[derive(Debug)] +#[derive(Clone, Copy, Debug)] pub enum Action { - CreateDatabase { parent: Option }, + CreateDatabase { + parent: Option, + organization: Option, + }, UpdateDatabase, ResetDatabase, DeleteDatabase, @@ -521,9 +524,13 @@ pub enum Action { impl fmt::Display for Action { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::CreateDatabase { parent } => match parent { - Some(parent) => write!(f, "create database with parent {}", parent), - None => f.write_str("create database"), + Self::CreateDatabase { parent, organization } => match (parent, organization) { + (Some(parent), Some(org)) => { + write!(f, "create database with parent {} and organization {}", parent, org) + } + (Some(parent), None) => write!(f, "create database with parent {}", parent), + (None, Some(org)) => write!(f, "create database with organization {}", org), + (None, None) => f.write_str("create database"), }, Self::UpdateDatabase => f.write_str("update database"), Self::ResetDatabase => f.write_str("reset database"), diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index f871e1e9d5d..79cce2e9775 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -650,6 +650,8 @@ pub struct PublishDatabaseQueryParams { #[serde(default)] host_type: HostType, parent: Option, + #[serde(alias = "org")] + organization: Option, } pub async fn publish( @@ -662,6 +664,7 @@ pub async fn publish( policy, host_type, parent, + organization, }): Query, Extension(auth): Extension, program_bytes: Bytes, @@ -709,6 +712,10 @@ pub async fn publish( None => None, Some(parent) => parent.resolve(&ctx).await.map(Some)?, }; + let maybe_org_identity = match organization.as_ref() { + None => None, + Some(org) => org.resolve(&ctx).await.map(Some)?, + }; // Check that the replication factor looks somewhat sane. let num_replicas = num_replicas.map(validate_replication_factor).transpose()?.flatten(); @@ -721,14 +728,17 @@ pub async fn publish( .await .map_err(log_and_500)?; match existing.as_ref() { - // If not, check that the we caller is sufficiently authenticated. + // If not, check that the caller is sufficiently authenticated. None => { allow_creation(&auth)?; - if let Some(parent) = maybe_parent_database_identity { + if maybe_parent_database_identity.is_some() || maybe_org_identity.is_some() { ctx.authorize_action( auth.claims.identity, database_identity, - Action::CreateDatabase { parent: Some(parent) }, + Action::CreateDatabase { + parent: maybe_parent_database_identity, + organization: maybe_org_identity, + }, ) .await?; } @@ -760,7 +770,7 @@ pub async fn publish( let schema_migration_policy = schema_migration_policy(policy, token)?; let maybe_updated = ctx .publish_database( - &auth.claims.identity, + maybe_org_identity.as_ref().unwrap_or(&auth.claims.identity), DatabaseDef { database_identity, program_bytes, From daadcc245046c2e37b1afbb303d4c480550918fc Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Tue, 20 Jan 2026 15:48:21 +0100 Subject: [PATCH 2/8] Add org arg to publish cmd --- crates/cli/src/subcommands/publish.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 2cfd5916f7e..d232dbce1d4 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -84,6 +84,18 @@ If a parent is given, the new database inherits the team permissions from the pa A parent can only be set when a database is created, not when it is updated." ) ) + .arg( + Arg::new("organization") + .help("Name or identity of an organization for this database") + .long("organization") + .alias("org") + .long_help( +"The name or identity of an existing organization this database should be created under. + +If an organization is given, the organization member's permissions apply to the new database. +An organization can only be set when a database is created, not when its is updated." + ) + ) .arg( Arg::new("name|identity") .help("A valid domain or identity for this database") @@ -139,6 +151,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let num_replicas = args.get_one::("num_replicas"); let force_break_clients = args.get_flag("break_clients"); let parent = args.get_one::("parent"); + let org = args.get_one::("organization"); // If the user didn't specify an identity and we didn't specify an anonymous identity, then // we want to use the default identity @@ -227,6 +240,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E if let Some(parent) = parent { builder = builder.query(&[("parent", parent)]); } + if let Some(org) = org { + builder = builder.query(&[("org", org)]); + } println!("Publishing module..."); From 1c7de17c0980e40700b7aaff8c82a341275d5283 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Wed, 21 Jan 2026 19:17:53 +0100 Subject: [PATCH 3/8] Adjust + add smoketests --- smoketests/__init__.py | 6 +- smoketests/tests/teams.py | 237 +++++++++++++++++++++++++++++++++++++- 2 files changed, 236 insertions(+), 7 deletions(-) diff --git a/smoketests/__init__.py b/smoketests/__init__.py index 657b7740361..284b05b8d46 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -235,7 +235,8 @@ def log_records(self, n): logs = self.spacetime("logs", "--format=json", "-n", str(n), "--", self.database_identity) return list(map(json.loads, logs.splitlines())) - def publish_module(self, domain=None, *, clear=True, capture_stderr=True, num_replicas=None, break_clients=False): + def publish_module(self, domain=None, *, clear=True, capture_stderr=True, + num_replicas=None, break_clients=False, organization=None): publish_output = self.spacetime( "publish", *[domain] if domain is not None else [], @@ -247,6 +248,7 @@ def publish_module(self, domain=None, *, clear=True, capture_stderr=True, num_re "--yes", *["--num-replicas", f"{num_replicas}"] if num_replicas is not None else [], *["--break-clients"] if break_clients else [], + *["--organization", f"{organization}"] if organization is not None else [], capture_stderr=capture_stderr, ) self.resolved_identity = re.search(r"identity: ([0-9a-fA-F]+)", publish_output)[1] @@ -406,7 +408,7 @@ def enterClassContext(cls, cm): result = cm.__enter__() cls.addClassCleanup(cm.__exit__, None, None, None) return result - + def assertSql(self, sql: str, expected: str): """Assert that executing `sql` produces the expected output.""" self.maxDiff = None diff --git a/smoketests/tests/teams.py b/smoketests/tests/teams.py index 5de6cd71f5f..4829dd0a92f 100644 --- a/smoketests/tests/teams.py +++ b/smoketests/tests/teams.py @@ -1,7 +1,16 @@ import json import toml -from .. import Smoketest, parse_sql_result, random_string +from .. import COMPOSE_FILE, Smoketest, parse_sql_result, random_string, spacetime +from ..docker import DockerManager +from ..tests.replication import Cluster + +OWNER = "Owner" +ADMIN = "Admin" +DEVELOPER = "Developer" +VIEWER = "Viewer" + +ROLES = [OWNER, ADMIN, DEVELOPER, VIEWER] class CreateChildDatabase(Smoketest): AUTOPUBLISH = False @@ -70,6 +79,18 @@ def test_change_database_hierarchy(self): class PermissionsTest(Smoketest): AUTOPUBLISH = False + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.root_config = cls.project_path / "root_config" + spacetime("--config-path", cls.root_config, "server", "set-default", "local") + + def setUp(self): + self.docker = DockerManager(COMPOSE_FILE) + self.root_token = self.docker.generate_root_token() + + self.cluster = Cluster(self.docker, self) + def create_identity(self): """ Obtain a fresh identity and token from the server. @@ -83,8 +104,7 @@ def create_collaborators(self, database): Create collaborators for the current database, one for each role. """ collaborators = {} - roles = ["Owner", "Admin", "Developer", "Viewer"] - for role in roles: + for role in ROLES: identity_and_token = self.create_identity() self.call_controldb_reducer( "upsert_collaborator", @@ -95,6 +115,37 @@ def create_collaborators(self, database): collaborators[role] = identity_and_token return collaborators + def create_organization(self): + """ + Create an organization with one member per role. + """ + members = {} + organization_identity = self.create_identity()['identity'] + for role in ROLES: + member = self.create_identity() + self.call_controldb_reducer( + "upsert_organization_member", + [f"0x{organization_identity}"], + [f"0x{member['identity']}"], + {role: {}} + ) + members[role] = member + organization = { + "organization": organization_identity, + "members": members + } + self.organization = organization + + return organization + + def make_admin(self): + """ + Create an admin account for the currently logged-in identity. + """ + identity = str(self.spacetime("login", "show")).split()[-1] + spacetime("--config-path", self.root_config, "login", "--token", self.root_token) + spacetime("--config-path", self.root_config, "call", + "spacetime-control", "create_admin_account", f"0x{identity}") def call_controldb_reducer(self, reducer, *args): """ @@ -134,6 +185,23 @@ def subscribe_as(self, role_and_token, *queries, n): self.login_with(role_and_token[1]) return self.subscribe(*queries, n = n) + def tearDown(self): + if "organization" in self.__dict__: + # Log in as owner + self.login_with(self.organization['members'][OWNER]) + # Delete database (requires org to still exist) + super().tearDown() + # Delete org + try: + self.call_controldb_reducer( + "delete_organization", + [f"0x{self.organization['organization']}"] + ) + except Exception: + pass + else: + super().tearDown() + class MutableSql(PermissionsTest): MODULE_CODE = """ @@ -154,7 +222,7 @@ def test_permissions_for_mutable_sql_transactions(self): for role, token in team.items(): self.login_with(token) dml = f"insert into person (name) values ('bob-the-{role}')" - if role == "Owner" or role == "Admin": + if role == OWNER or role == ADMIN: self.spacetime("sql", name, dml) else: with self.assertRaises(Exception): @@ -334,7 +402,7 @@ def test_permissions_private_tables(self): self.publish_module(parent) team = self.create_collaborators(parent) - owner = ("Owner", team['Owner']) + owner = (OWNER, team[OWNER]) self.sql_as(owner, parent, "insert into person (name) values ('horsti')") @@ -364,3 +432,162 @@ def test_permissions_private_tables(self): } ], ) + + +class OrgMutableSql(MutableSql): + MODULE_CODE = """ +#[spacetimedb::table(name = person, public)] +struct Person { + name: String, +} +""" + + def test_org_permissions_for_mutable_sql_transactions(self): + """ + Tests that only organization owners and admins can perform mutable SQL + transactions. + """ + + self.make_admin() + org = self.create_organization() + database_name = random_string() + + self.login_with(org['members'][OWNER]) + self.publish_module(database_name, organization = f"0x{org['organization']}") + + for role, auth in org['members'].items(): + self.login_with(auth) + dml = f"insert into person (name) values ('bob-the-{role}')" + if role == OWNER or role == ADMIN: + self.spacetime("sql", database_name, dml) + else: + with self.assertRaises(Exception): + self.spacetime("sql", database_name, dml) + + +class OrgPublishDatabase(PublishDatabase): + def test_org_permissions_publish(self): + """ + Tests that only organization owner, admin and developer roles can + publish a database. + """ + + self.make_admin() + org = self.create_organization() + database_name = random_string() + + self.spacetime("sql", "spacetime-control", "select * from organization_member") + self.login_with(org['members'][OWNER]) + self.publish_module(database_name, organization = f"0x{org['organization']}") + + succeed_with = [ + (OWNER, self.MODULE_CODE_OWNER), + (ADMIN, self.MODULE_CODE_ADMIN), + (DEVELOPER, self.MODULE_CODE_DEVELOPER), + ] + + def role_and_auth(org, role): + return [role, org['members'][role]] + + for role, code in succeed_with: + self.publish_as(role_and_auth(org, role), database_name, code) + + with self.assertRaises(Exception): + self.publish_as(role_and_auth(org, VIEWER), database_name, self.MODULE_CODE_VIEWER) + + +class OrgClearDatabase(ClearDatabase): + def test_org_permissions_clear(self): + """ + Test that only organization owners or admins can clear a database. + """ + + self.make_admin() + org = self.create_organization() + database_name = random_string() + + self.login_with(org['members'][OWNER]) + self.publish_module(database_name, organization = f"0x{org['organization']}") + + def role_and_auth(org, role): + return [role, org['members'][role]] + + # Owner and admin can clear. + for role in [OWNER, ADMIN]: + self.publish_as(role_and_auth(org, role), database_name, self.MODULE_CODE, clear = True) + + # Others can't. + for role in [DEVELOPER, VIEWER]: + with self.assertRaises(Exception): + self.publish_as(role_and_auth(org, role), database_name, self.MODULE_CODE, clear = True) + + +class OrgDeleteDatabase(DeleteDatabase): + def test_org_permissions_delete(self): + """ + Tests that only organization owners can delete databases. + """ + + self.make_admin() + org = self.create_organization() + database_name = random_string() + + self.login_with(org['members'][OWNER]) + self.publish_module(database_name, organization = f"0x{org['organization']}") + + def role_and_auth(org, role): + return [role, org['members'][role]] + + for role in ROLES: + if role == OWNER: + continue + + with self.assertRaises(Exception): + self.delete_as(role_and_auth(org, role), database_name) + + self.delete_as(role_and_auth(org, OWNER), database_name) + + +class OrgPrivateTables(PrivateTables): + def test_org_permissions_private_tables(self): + """ + Test that all organization members can read private tables. + """ + + self.make_admin() + org = self.create_organization() + database_name = random_string() + + self.login_with(org['members'][OWNER]) + self.publish_module(database_name, organization = f"0x{org['organization']}") + + owner = [OWNER, org['members'][OWNER]] + + self.sql_as(owner, database_name, "insert into person (name) values ('horsti')") + + for auth in org['members'].items(): + rows = self.sql_as(auth, database_name, "select * from person") + self.assertEqual(rows, [{ "name": '"horsti"' }]) + + for auth in org['members'].items(): + sub = self.subscribe_as(auth, "select * from person", n = 2) + self.sql_as(owner, database_name, "insert into person (name) values ('hansmans')") + self.sql_as(owner, database_name, "delete from person where name = 'hansmans'") + res = sub() + self.assertEqual( + res, + [ + { + 'person': { + 'deletes': [], + 'inserts': [{'name': 'hansmans'}] + } + }, + { + 'person': { + 'deletes': [{'name': 'hansmans'}], + 'inserts': [] + } + } + ], + ) From 410caf97cc94f5835ce8695d7b103817e6d9a810 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Wed, 21 Jan 2026 19:36:14 +0100 Subject: [PATCH 4/8] Regen CLI docs --- .../00100-cli-reference/00100-cli-reference.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 508f87a399f..8a6a4ff52a1 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -108,6 +108,10 @@ Run `spacetime help publish` for more detailed information. If a parent is given, the new database inherits the team permissions from the parent. A parent can only be set when a database is created, not when it is updated. +* `--organization ` — The name or identity of an existing organization this database should be created under. + + If an organization is given, the organization member's permissions apply to the new database. + An organization can only be set when a database is created, not when its is updated. * `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). From 460d891ff725e6520d212c377979de2952c45fec Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 22 Jan 2026 15:01:27 +0100 Subject: [PATCH 5/8] Setting the organization as owner needs to be done in the controldb --- crates/client-api/src/lib.rs | 4 ++++ crates/client-api/src/routes/database.rs | 26 +++++++++++------------- crates/testing/src/modules.rs | 1 + 3 files changed, 17 insertions(+), 14 deletions(-) diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index b3c6bea0dff..a0a4c6f4871 100644 --- a/crates/client-api/src/lib.rs +++ b/crates/client-api/src/lib.rs @@ -204,7 +204,11 @@ pub struct DatabaseDef { pub num_replicas: Option, /// The host type of the supplied program. pub host_type: HostType, + /// The optional identity of an existing database the database shall be a + /// child of. pub parent: Option, + /// The optional identity of an organization the database shall belong to. + pub organization: Option, } /// Parameters for resetting a database via [`ControlStateDelegate::reset_database`]. diff --git a/crates/client-api/src/routes/database.rs b/crates/client-api/src/routes/database.rs index 79cce2e9775..8771fda252e 100644 --- a/crates/client-api/src/routes/database.rs +++ b/crates/client-api/src/routes/database.rs @@ -728,22 +728,18 @@ pub async fn publish( .await .map_err(log_and_500)?; match existing.as_ref() { - // If not, check that the caller is sufficiently authenticated. None => { allow_creation(&auth)?; - if maybe_parent_database_identity.is_some() || maybe_org_identity.is_some() { - ctx.authorize_action( - auth.claims.identity, - database_identity, - Action::CreateDatabase { - parent: maybe_parent_database_identity, - organization: maybe_org_identity, - }, - ) - .await?; - } + ctx.authorize_action( + auth.claims.identity, + database_identity, + Action::CreateDatabase { + parent: maybe_parent_database_identity, + organization: maybe_org_identity, + }, + ) + .await?; } - // If yes, authorize via ctx. Some(database) => { ctx.authorize_action(auth.claims.identity, database.database_identity, Action::UpdateDatabase) .await?; @@ -770,13 +766,14 @@ pub async fn publish( let schema_migration_policy = schema_migration_policy(policy, token)?; let maybe_updated = ctx .publish_database( - maybe_org_identity.as_ref().unwrap_or(&auth.claims.identity), + &auth.claims.identity, DatabaseDef { database_identity, program_bytes, num_replicas, host_type, parent, + organization: maybe_org_identity, }, schema_migration_policy, ) @@ -931,6 +928,7 @@ pub async fn pre_publish num_replicas: None, host_type, parent: None, + organization: None, }, style, ) diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs index 68ae6cf5f44..2809e639701 100644 --- a/crates/testing/src/modules.rs +++ b/crates/testing/src/modules.rs @@ -221,6 +221,7 @@ impl CompiledModule { num_replicas: None, host_type: self.host_type, parent: None, + organization: None, }, MigrationPolicy::Compatible, ) From fd23b52815660bb1d6fb88aa7e0d6ae83c379b79 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 22 Jan 2026 15:01:56 +0100 Subject: [PATCH 6/8] Reorganize tests to cover the parent/child shenanigans --- smoketests/tests/teams.py | 425 +++++++++++++++++--------------------- 1 file changed, 195 insertions(+), 230 deletions(-) diff --git a/smoketests/tests/teams.py b/smoketests/tests/teams.py index 4829dd0a92f..f49e946c2c6 100644 --- a/smoketests/tests/teams.py +++ b/smoketests/tests/teams.py @@ -12,6 +12,9 @@ ROLES = [OWNER, ADMIN, DEVELOPER, VIEWER] +def get(d: dict, k): + return (k, d[k]) + class CreateChildDatabase(Smoketest): AUTOPUBLISH = False @@ -76,7 +79,7 @@ def test_change_database_hierarchy(self): self.publish_module(f"{parent_name}/{child_name}", clear = False) -class PermissionsTest(Smoketest): +class TeamsPermissionsTest(Smoketest): AUTOPUBLISH = False @classmethod @@ -160,12 +163,13 @@ def login_with(self, identity_and_token: dict): with open(self.config_path, 'w') as f: toml.dump(config, f) - def publish_as(self, role_and_token, module, code, clear = False): - print(f"publishing {module} as {role_and_token[0]}:") + def publish_as(self, role_and_token, module, code = None, clear = False, org = None): + print(f"publishing {module} with org {org} as {role_and_token[0]}:") + code = self.MODULE_CODE if code is None else code print(f"{code}") self.login_with(role_and_token[1]) self.write_module_code(code) - self.publish_module(module, clear = clear) + self.publish_module(module, clear = clear, organization = org) return self.database_identity def sql_as(self, role_and_token, database, sql): @@ -203,33 +207,55 @@ def tearDown(self): super().tearDown() -class MutableSql(PermissionsTest): +class TeamsMutableSql(TeamsPermissionsTest): MODULE_CODE = """ #[spacetimedb::table(name = person, public)] struct Person { name: String, } """ - def test_permissions_for_mutable_sql_transactions(self): - """ - Tests that only owners and admins can perform mutable SQL transactions. - """ - - name = random_string() - self.publish_module(name) - team = self.create_collaborators(name) + def run_test(self, database, team): for role, token in team.items(): self.login_with(token) dml = f"insert into person (name) values ('bob-the-{role}')" if role == OWNER or role == ADMIN: - self.spacetime("sql", name, dml) + self.spacetime("sql", database, dml) else: with self.assertRaises(Exception): - self.spacetime("sql", name, dml) + self.spacetime("sql", database, dml) + +class CollaboratorsMutableSql(TeamsMutableSql): + def test_permissions_mut_sql_collaborators(self): + """ + Tests that only owner and admin collaborators can perform mutable SQL + transactions. + """ + + name = random_string() + self.publish_module(name) + team = self.create_collaborators(name) + self.run_test(name, team) + + +class OrgMutableSql(TeamsMutableSql): + def test_org_permissions_mut_sql_org_members(self): + """ + Tests that only owner and admin organization members can perform mutable + SQL transactions. + """ + + self.make_admin() + org = self.create_organization() + name = random_string() + + self.login_with(org['members'][OWNER]) + self.publish_module(name, organization = f"0x{org['organization']}") + + self.run_test(name, org['members']) -class PublishDatabase(PermissionsTest): +class TeamsPublishDatabase(TeamsPermissionsTest): MODULE_CODE = """ #[spacetimedb::table(name = person, public)] struct Person { @@ -265,50 +291,91 @@ class PublishDatabase(PermissionsTest): } """ - def test_permissions_publish(self): + MODULES = { + OWNER: MODULE_CODE_OWNER, + ADMIN: MODULE_CODE_ADMIN, + DEVELOPER: MODULE_CODE_DEVELOPER, + VIEWER: MODULE_CODE_VIEWER + } + + def run_test(self, parent, child, team, org): + self.assert_all_except_viewer_can_update(parent, team, org = org) + + # Create a child database. + child_path = f"{parent}/{child}" + + # Developer and viewer should not be able to create a child. + for role in [DEVELOPER, VIEWER]: + with self.assertRaises(Exception): + self.publish_as(get(team, role), child_path, self.MODULE_CODE, org = org) + # But admin should succeed. + self.publish_as(get(team, ADMIN), child_path, self.MODULE_CODE, org = org) + + # Once created, only viewer should be denied updating. + self.assert_all_except_viewer_can_update(child_path, team, org) + + def assert_all_except_viewer_can_update(self, database, team, org): + for role in [OWNER, ADMIN, DEVELOPER]: + self.publish_as(get(team, role), database, self.MODULES[role], org = org) + + with self.assertRaises(Exception): + self.publish_as(get(team, VIEWER), database, self.MODULES[VIEWER], org = org) + + +class CollaboratorsPublishDatabase(TeamsPublishDatabase): + def test_permissions_publish_collaborators(self): """ - Tests that only owner, admin and developer roles can publish a database. + Tests that only owner, admin and developer collaborators can publish a + database. """ parent = random_string() + child = random_string() self.publish_module(parent) + team = self.create_collaborators(parent) - (owner, admin, developer, viewer) = self.create_collaborators(parent).items() - succeed_with = [ - (owner, self.MODULE_CODE_OWNER), - (admin, self.MODULE_CODE_ADMIN), - (developer, self.MODULE_CODE_DEVELOPER) - ] + self.run_test(parent, child, team, org = None) - for role_and_token, code in succeed_with: - self.publish_as(role_and_token, parent, code) - with self.assertRaises(Exception): - self.publish_as(viewer, parent, self.MODULE_CODE_VIEWER) +class OrgPublishDatabase(TeamsPublishDatabase): + def test_permissions_publish_org_members(self): + """ + Tests that only owner, admin and developer organization members can + publish a database. + """ - # Create a child database. + self.make_admin() + org = self.create_organization() + parent = random_string() child = random_string() - child_path = f"{parent}/{child}" - # Developer and viewer should not be able to create a child. - for role_and_token in [developer, viewer]: - with self.assertRaises(Exception): - self.publish_as(role_and_token, child_path, self.MODULE_CODE) - # But admin should succeed. - self.publish_as(admin, child_path, self.MODULE_CODE) + self.login_with(org['members'][OWNER]) + self.publish_module(parent, organization = f"0x{org['organization']}") - # Once created, only viewer should be denied updating. - for role_and_token, code in succeed_with: - self.publish_as(role_and_token, child_path, code) + self.run_test(parent, child, org['members'], + org = org['organization']) + +class TeamsClearDatabase(TeamsPermissionsTest): + def assert_can_clear(self, auth, database): + self.publish_as(auth, database, clear = True) + + def assert_cannot_clear(self, auth, database): with self.assertRaises(Exception): - self.publish_as(viewer, child_path, self.MODULE_CODE_VIEWER) + self.publish_as(auth, database, clear = True) + + def assert_clear_permissions(self, team, database): + for role in [OWNER, ADMIN]: + self.assert_can_clear(get(team, role), database) + + for role in [DEVELOPER, VIEWER]: + self.assert_cannot_clear(get(team, role), database) -class ClearDatabase(PermissionsTest): - def test_permissions_clear(self): +class CollaboratorsClearDatabase(TeamsClearDatabase): + def test_permissions_clear_collaborators(self): """ - Tests that only owners and admins can clear a database. + Tests that only owner and admin collaborators can clear a database. """ parent = random_string() @@ -316,104 +383,132 @@ def test_permissions_clear(self): # First degree owner can clear. self.publish_module(parent, clear = True) - (owner, admin, developer, viewer) = self.create_collaborators(parent).items() + team = self.create_collaborators(parent) + self.assert_clear_permissions(team, parent) - # Owner and admin collaborators can clear. - for role_and_token in [owner, admin]: - self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) + # Child databases cannot be cleared at all + child = f"{parent}/{random_string()}" + self.publish_as(get(team, OWNER), child) + for auth in team.items(): + self.assert_cannot_clear(auth, child) - # Others can't. - for role_and_token in [developer, viewer]: - with self.assertRaises(Exception): - self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) - # Same applies to child. - child = random_string() - child_path = f"{parent}/{child}" +class OrgClearDatabase(TeamsClearDatabase): + def test_permissions_clear_org(self): + """ + Test that only owner or admin org members can clear a database. + """ - self.publish_as(owner, child_path, self.MODULE_CODE) + self.make_admin() + org = self.create_organization() + team = org['members'] - for role_and_token in [owner, admin]: - self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) + parent = random_string() - for role_and_token in [developer, viewer]: - with self.assertRaises(Exception): - self.publish_as(role_and_token, parent, self.MODULE_CODE, clear = True) + self.login_with(org['members'][OWNER]) + self.publish_module(parent, organization = f"0x{org['organization']}") + self.assert_clear_permissions(team, parent) + # Child databases cannot be cleared at all + child = f"{parent}/{random_string()}" + self.publish_as(get(team, ADMIN), child) + for auth in team.items(): + self.assert_cannot_clear(auth, child) -class DeleteDatabase(PermissionsTest): + +class TeamsDeleteDatabase(TeamsPermissionsTest): def delete_as(self, role_and_token, database): print(f"delete {database} as {role_and_token[0]}") self.login_with(role_and_token[1]) self.spacetime("delete", "--yes", database) - def test_permissions_delete(self): + +class CollaboratorsDeleteDatabase(TeamsDeleteDatabase): + def test_permissions_delete_collaborators(self): """ Tests that only owners can delete databases. """ parent = random_string() + child = random_string() self.publish_module(parent) self.spacetime("delete", "--yes", parent) self.publish_module(parent) - (owner, admin, developer, viewer) = self.create_collaborators(parent).items() - - for role_and_token in [admin, developer, viewer]: + team = self.create_collaborators(parent) + for role in [ADMIN, DEVELOPER, VIEWER]: with self.assertRaises(Exception): - self.delete_as(role_and_token, parent) + self.delete_as(get(team, role), parent) - child = random_string() child_path = f"{parent}/{child}" # If admin creates a child, they should also be able to delete it, # because they are the owner of the child. print("publish and delete as admin") - self.publish_as(admin, child_path, self.MODULE_CODE) - self.delete_as(admin, child) + self.publish_as(get(team, ADMIN), child_path) + self.delete_as(get(team, ADMIN), child) # The owner role should be able to delete. print("publish as admin, delete as owner") - self.publish_as(admin, child_path, self.MODULE_CODE) - self.delete_as(owner, child) + self.publish_as(get(team, ADMIN), child_path) + self.delete_as(get(team, OWNER), child) # Anyone else should be denied if not direct owner. print("publish as owner, deny deletion by admin, developer, viewer") - self.publish_as(owner, child_path, self.MODULE_CODE) - for role_and_token in [admin, developer, viewer]: + self.publish_as(get(team, OWNER), child_path) + for role in [ADMIN, DEVELOPER, VIEWER]: with self.assertRaises(Exception): - self.delete_as(role_and_token, child) + self.delete_as(get(team, role), child) print("delete child as owner") - self.delete_as(owner, child) + self.delete_as(get(team, OWNER), child) print("delete parent as owner") - self.delete_as(owner, parent) + self.delete_as(get(team, OWNER), parent) -class PrivateTables(PermissionsTest): - def test_permissions_private_tables(self): +class OrgDeleteDatabase(TeamsDeleteDatabase): + def test_permissions_delete_org(self): """ - Test that all collaborators can read private tables. + Tests that only organization owners can delete databases. """ + self.make_admin() + org = self.create_organization() + team = org['members'] parent = random_string() - self.publish_module(parent) + child = random_string() - team = self.create_collaborators(parent) - owner = (OWNER, team[OWNER]) + self.login_with(org['members'][OWNER]) + self.publish_module(parent, organization = f"0x{org['organization']}") + self.publish_module(f"{parent}/{child}") + + # Org databases can only be deleted by owners + # because ownership is transferred to the org + # and publisher attribution is lost. + for database in [child, parent]: + for role in [ADMIN, DEVELOPER, VIEWER]: + with self.assertRaises(Exception): + self.delete_as(get(team, role), database) - self.sql_as(owner, parent, "insert into person (name) values ('horsti')") + self.delete_as(get(team, OWNER), child) + self.delete_as(get(team, OWNER), parent) - for role_and_token in team.items(): - rows = self.sql_as(role_and_token, parent, "select * from person") + +class TeamsPrivateTables(TeamsPermissionsTest): + def run_test(self, database, team): + owner = get(team, OWNER) + self.sql_as(owner, database, "insert into person (name) values ('horsti')") + + for auth in team.items(): + rows = self.sql_as(auth, database, "select * from person") self.assertEqual(rows, [{ "name": '"horsti"' }]) - for role_and_token in team.items(): - sub = self.subscribe_as(role_and_token, "select * from person", n = 2) - self.sql_as(owner, parent, "insert into person (name) values ('hansmans')") - self.sql_as(owner, parent, "delete from person where name = 'hansmans'") + for auth in team.items(): + sub = self.subscribe_as(auth, "select * from person", n = 2) + self.sql_as(owner, database, "insert into person (name) values ('hansmans')") + self.sql_as(owner, database, "delete from person where name = 'hansmans'") res = sub() self.assertEqual( res, @@ -434,121 +529,20 @@ def test_permissions_private_tables(self): ) -class OrgMutableSql(MutableSql): - MODULE_CODE = """ -#[spacetimedb::table(name = person, public)] -struct Person { - name: String, -} -""" - - def test_org_permissions_for_mutable_sql_transactions(self): - """ - Tests that only organization owners and admins can perform mutable SQL - transactions. - """ - - self.make_admin() - org = self.create_organization() - database_name = random_string() - - self.login_with(org['members'][OWNER]) - self.publish_module(database_name, organization = f"0x{org['organization']}") - - for role, auth in org['members'].items(): - self.login_with(auth) - dml = f"insert into person (name) values ('bob-the-{role}')" - if role == OWNER or role == ADMIN: - self.spacetime("sql", database_name, dml) - else: - with self.assertRaises(Exception): - self.spacetime("sql", database_name, dml) - - -class OrgPublishDatabase(PublishDatabase): - def test_org_permissions_publish(self): - """ - Tests that only organization owner, admin and developer roles can - publish a database. - """ - - self.make_admin() - org = self.create_organization() - database_name = random_string() - - self.spacetime("sql", "spacetime-control", "select * from organization_member") - self.login_with(org['members'][OWNER]) - self.publish_module(database_name, organization = f"0x{org['organization']}") - - succeed_with = [ - (OWNER, self.MODULE_CODE_OWNER), - (ADMIN, self.MODULE_CODE_ADMIN), - (DEVELOPER, self.MODULE_CODE_DEVELOPER), - ] - - def role_and_auth(org, role): - return [role, org['members'][role]] - - for role, code in succeed_with: - self.publish_as(role_and_auth(org, role), database_name, code) - - with self.assertRaises(Exception): - self.publish_as(role_and_auth(org, VIEWER), database_name, self.MODULE_CODE_VIEWER) - - -class OrgClearDatabase(ClearDatabase): - def test_org_permissions_clear(self): - """ - Test that only organization owners or admins can clear a database. - """ - - self.make_admin() - org = self.create_organization() - database_name = random_string() - - self.login_with(org['members'][OWNER]) - self.publish_module(database_name, organization = f"0x{org['organization']}") - - def role_and_auth(org, role): - return [role, org['members'][role]] - - # Owner and admin can clear. - for role in [OWNER, ADMIN]: - self.publish_as(role_and_auth(org, role), database_name, self.MODULE_CODE, clear = True) - - # Others can't. - for role in [DEVELOPER, VIEWER]: - with self.assertRaises(Exception): - self.publish_as(role_and_auth(org, role), database_name, self.MODULE_CODE, clear = True) - - -class OrgDeleteDatabase(DeleteDatabase): - def test_org_permissions_delete(self): +class CollaboratorsPrivateTables(TeamsPrivateTables): + def test_permissions_private_tables(self): """ - Tests that only organization owners can delete databases. + Test that all collaborators can read private tables. """ - self.make_admin() - org = self.create_organization() - database_name = random_string() - - self.login_with(org['members'][OWNER]) - self.publish_module(database_name, organization = f"0x{org['organization']}") + database = random_string() + self.publish_module(database) - def role_and_auth(org, role): - return [role, org['members'][role]] + team = self.create_collaborators(database) + self.run_test(database, team) - for role in ROLES: - if role == OWNER: - continue - with self.assertRaises(Exception): - self.delete_as(role_and_auth(org, role), database_name) - - self.delete_as(role_and_auth(org, OWNER), database_name) - - -class OrgPrivateTables(PrivateTables): +class OrgPrivateTables(TeamsPrivateTables): def test_org_permissions_private_tables(self): """ Test that all organization members can read private tables. @@ -556,38 +550,9 @@ def test_org_permissions_private_tables(self): self.make_admin() org = self.create_organization() - database_name = random_string() + database = random_string() self.login_with(org['members'][OWNER]) - self.publish_module(database_name, organization = f"0x{org['organization']}") - - owner = [OWNER, org['members'][OWNER]] + self.publish_module(database, organization = f"0x{org['organization']}") - self.sql_as(owner, database_name, "insert into person (name) values ('horsti')") - - for auth in org['members'].items(): - rows = self.sql_as(auth, database_name, "select * from person") - self.assertEqual(rows, [{ "name": '"horsti"' }]) - - for auth in org['members'].items(): - sub = self.subscribe_as(auth, "select * from person", n = 2) - self.sql_as(owner, database_name, "insert into person (name) values ('hansmans')") - self.sql_as(owner, database_name, "delete from person where name = 'hansmans'") - res = sub() - self.assertEqual( - res, - [ - { - 'person': { - 'deletes': [], - 'inserts': [{'name': 'hansmans'}] - } - }, - { - 'person': { - 'deletes': [{'name': 'hansmans'}], - 'inserts': [] - } - } - ], - ) + self.run_test(database, org['members']) From 87eb5158b49618bcb777e373a495df469b40da64 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 22 Jan 2026 19:34:44 +0100 Subject: [PATCH 7/8] How could this have ever worked? --- crates/standalone/src/lib.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/standalone/src/lib.rs b/crates/standalone/src/lib.rs index 900042632db..2aa4e78740f 100644 --- a/crates/standalone/src/lib.rs +++ b/crates/standalone/src/lib.rs @@ -478,6 +478,13 @@ impl spacetimedb_client_api::Authorization for StandaloneEnv { database: Identity, action: spacetimedb_client_api::Action, ) -> Result<(), spacetimedb_client_api::Unauthorized> { + // Creating a database is always allowed. + if let spacetimedb_client_api::Action::CreateDatabase { .. } = action { + return Ok(()); + } + + // Otherwise, the database must already exist, + // and the `subject` equal to `database.owner_identity`. let database = self .get_database_by_identity(&database) .await? From 16d8f25fafc0d42d37525b1c1402b4e13dd3b508 Mon Sep 17 00:00:00 2001 From: Kim Altintop Date: Thu, 22 Jan 2026 20:19:09 +0100 Subject: [PATCH 8/8] Typofix --- crates/cli/src/subcommands/publish.rs | 2 +- .../00200-reference/00100-cli-reference/00100-cli-reference.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index d232dbce1d4..9e5ef1fc650 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -93,7 +93,7 @@ A parent can only be set when a database is created, not when it is updated." "The name or identity of an existing organization this database should be created under. If an organization is given, the organization member's permissions apply to the new database. -An organization can only be set when a database is created, not when its is updated." +An organization can only be set when a database is created, not when it is updated." ) ) .arg( diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 8a6a4ff52a1..72778dec5b4 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -111,7 +111,7 @@ Run `spacetime help publish` for more detailed information. * `--organization ` — The name or identity of an existing organization this database should be created under. If an organization is given, the organization member's permissions apply to the new database. - An organization can only be set when a database is created, not when its is updated. + An organization can only be set when a database is created, not when it is updated. * `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com).