diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 2cfd5916f7e..9e5ef1fc650 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 it 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..."); diff --git a/crates/client-api/src/lib.rs b/crates/client-api/src/lib.rs index 1ccadc877cd..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`]. @@ -508,9 +512,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 +528,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..8771fda252e 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,19 +728,18 @@ pub async fn publish( .await .map_err(log_and_500)?; match existing.as_ref() { - // If not, check that the we caller is sufficiently authenticated. None => { allow_creation(&auth)?; - if let Some(parent) = maybe_parent_database_identity { - ctx.authorize_action( - auth.claims.identity, - database_identity, - Action::CreateDatabase { parent: Some(parent) }, - ) - .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?; @@ -767,6 +773,7 @@ pub async fn publish( num_replicas, host_type, parent, + organization: maybe_org_identity, }, schema_migration_policy, ) @@ -921,6 +928,7 @@ pub async fn pre_publish num_replicas: None, host_type, parent: None, + organization: None, }, style, ) 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? 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, ) 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..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 @@ -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 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). 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..f49e946c2c6 100644 --- a/smoketests/tests/teams.py +++ b/smoketests/tests/teams.py @@ -1,7 +1,19 @@ 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] + +def get(d: dict, k): + return (k, d[k]) class CreateChildDatabase(Smoketest): AUTOPUBLISH = False @@ -67,9 +79,21 @@ 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 + 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 +107,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 +118,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): """ @@ -109,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): @@ -134,34 +189,73 @@ def subscribe_as(self, role_and_token, *queries, n): self.login_with(role_and_token[1]) return self.subscribe(*queries, n = n) - -class MutableSql(PermissionsTest): + 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 TeamsMutableSql(TeamsPermissionsTest): MODULE_CODE = """ #[spacetimedb::table(name = person, public)] struct Person { name: String, } """ - def test_permissions_for_mutable_sql_transactions(self): + + 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", database, dml) + else: + with self.assertRaises(Exception): + self.spacetime("sql", database, dml) + +class CollaboratorsMutableSql(TeamsMutableSql): + def test_permissions_mut_sql_collaborators(self): """ - Tests that only owners and admins can perform mutable SQL transactions. + 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) - 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) - else: - with self.assertRaises(Exception): - self.spacetime("sql", name, dml) +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']}") -class PublishDatabase(PermissionsTest): + self.run_test(name, org['members']) + + +class TeamsPublishDatabase(TeamsPermissionsTest): MODULE_CODE = """ #[spacetimedb::table(name = person, public)] struct Person { @@ -197,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() @@ -248,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.delete_as(get(team, OWNER), child) + self.delete_as(get(team, OWNER), parent) - self.sql_as(owner, parent, "insert into person (name) values ('horsti')") - 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, @@ -364,3 +527,32 @@ def test_permissions_private_tables(self): } ], ) + + +class CollaboratorsPrivateTables(TeamsPrivateTables): + def test_permissions_private_tables(self): + """ + Test that all collaborators can read private tables. + """ + + database = random_string() + self.publish_module(database) + + team = self.create_collaborators(database) + self.run_test(database, team) + + +class OrgPrivateTables(TeamsPrivateTables): + def test_org_permissions_private_tables(self): + """ + Test that all organization members can read private tables. + """ + + self.make_admin() + org = self.create_organization() + database = random_string() + + self.login_with(org['members'][OWNER]) + self.publish_module(database, organization = f"0x{org['organization']}") + + self.run_test(database, org['members'])