From ea196e6c008f6c8ee56277023a892c642860499c Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 22 Oct 2026 21:29:06 +0000 Subject: [PATCH 1/7] feat(share-permissions): let the team share writer manage databases Why: databases could not be shared with the owner's team because the team share writer only knew documents, threads, sessions and the other shareable entities, and databases had nowhere to keep sharing state. What: - Add `database_entities.share_permission_id`, a SharePermission row made the first time the owner changes sharing; the delete trigger removes it with the entity. - Teach `share_permission_db_utils::team_share` to load and apply a database's team share, read a database without a permission row as unshared, and treat a trashed database as deleted. --- ...6c02e79b38d8846f5ec782b5967af95c3a84.json} | 4 +- ...80d0c4eb86b8723fe7fadfc26c8a8080f5dd.json} | 4 +- .../20261021183145_database_team_sharing.sql | 16 ++++++++ .../src/team_share.rs | 12 +++++- .../src/team_share/test.rs | 39 +++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) rename .sqlx/{query-f1ee541b0fd4854bf373d75d224f61f5ba26e0b11150f19d047788668b30f3ed.json => query-739a30b2ae7711752ecf7971ba176c02e79b38d8846f5ec782b5967af95c3a84.json} (73%) rename .sqlx/{query-f0a958d1d92f196a2b6b7d5d75ce322ad79745abe807537a2d8c6c703fd6c7a6.json => query-e4dd957d19dc07703f1c78cd3e2980d0c4eb86b8723fe7fadfc26c8a8080f5dd.json} (72%) create mode 100644 crates/macro_db_client/migrations/20261021183145_database_team_sharing.sql diff --git a/.sqlx/query-f1ee541b0fd4854bf373d75d224f61f5ba26e0b11150f19d047788668b30f3ed.json b/.sqlx/query-739a30b2ae7711752ecf7971ba176c02e79b38d8846f5ec782b5967af95c3a84.json similarity index 73% rename from .sqlx/query-f1ee541b0fd4854bf373d75d224f61f5ba26e0b11150f19d047788668b30f3ed.json rename to .sqlx/query-739a30b2ae7711752ecf7971ba176c02e79b38d8846f5ec782b5967af95c3a84.json index 91b858604b0..d39d8b9d921 100644 --- a/.sqlx/query-f1ee541b0fd4854bf373d75d224f61f5ba26e0b11150f19d047788668b30f3ed.json +++ b/.sqlx/query-739a30b2ae7711752ecf7971ba176c02e79b38d8846f5ec782b5967af95c3a84.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "WITH entity AS (\n SELECT d.owner, dp.\"sharePermissionId\" AS permission_id\n FROM \"Document\" d LEFT JOIN \"DocumentPermission\" dp ON dp.\"documentId\" = d.id\n WHERE $2 = 'document' AND d.id = $1\n UNION ALL\n SELECT p.\"userId\", pp.\"sharePermissionId\"\n FROM \"Project\" p LEFT JOIN \"ProjectPermission\" pp ON pp.\"projectId\" = p.id\n WHERE $2 = 'project' AND p.id = $1\n UNION ALL\n SELECT c.\"userId\", cp.\"sharePermissionId\"\n FROM \"Chat\" c LEFT JOIN \"ChatPermission\" cp ON cp.\"chatId\" = c.id\n WHERE $2 = 'chat' AND c.id = $1\n UNION ALL\n SELECT l.macro_id, tp.\"sharePermissionId\"\n FROM email_threads t JOIN email_links l ON l.id = t.link_id\n LEFT JOIN \"EmailThreadPermission\" tp ON tp.\"threadId\" = t.id::text\n WHERE $2 = 'email_thread' AND t.id = $3\n UNION ALL\n SELECT c.user_id, c.share_permission_id FROM call_entities c\n WHERE $2 = 'call' AND c.id = $3\n UNION ALL\n SELECT i.owner_user_id, i.share_permission_id FROM initiative i\n WHERE $2 = 'initiative' AND i.id = $3\n UNION ALL\n SELECT s.owner_id, s.share_permission_id FROM agent_session s\n WHERE $2 = 'agent_session' AND s.id = $3\n )\n SELECT e.owner AS \"owner!\", e.permission_id,\n sp.id AS \"stored_permission_id?\",\n sp.team_share_access_level AS \"level: AccessLevel\",\n sp.team_share_team_id AS team_id, sp.team_share_revision AS \"revision?\",\n tu.team_id AS \"user_team?\", b.team_id AS \"bot_team?\", btu.team_id AS \"bot_user_team?\"\n FROM entity e LEFT JOIN \"SharePermission\" sp ON sp.id = e.permission_id\n LEFT JOIN team_user tu ON tu.user_id = e.owner\n LEFT JOIN bots b ON b.id = CASE\n WHEN e.owner ~ '^bot\\|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'\n THEN substring(e.owner FROM 5)::uuid END\n LEFT JOIN team_user btu ON btu.user_id = b.owner_user_id", + "query": "WITH entity AS (\n SELECT d.owner, dp.\"sharePermissionId\" AS permission_id\n FROM \"Document\" d LEFT JOIN \"DocumentPermission\" dp ON dp.\"documentId\" = d.id\n WHERE $2 = 'document' AND d.id = $1\n UNION ALL\n SELECT p.\"userId\", pp.\"sharePermissionId\"\n FROM \"Project\" p LEFT JOIN \"ProjectPermission\" pp ON pp.\"projectId\" = p.id\n WHERE $2 = 'project' AND p.id = $1\n UNION ALL\n SELECT c.\"userId\", cp.\"sharePermissionId\"\n FROM \"Chat\" c LEFT JOIN \"ChatPermission\" cp ON cp.\"chatId\" = c.id\n WHERE $2 = 'chat' AND c.id = $1\n UNION ALL\n SELECT l.macro_id, tp.\"sharePermissionId\"\n FROM email_threads t JOIN email_links l ON l.id = t.link_id\n LEFT JOIN \"EmailThreadPermission\" tp ON tp.\"threadId\" = t.id::text\n WHERE $2 = 'email_thread' AND t.id = $3\n UNION ALL\n SELECT c.user_id, c.share_permission_id FROM call_entities c\n WHERE $2 = 'call' AND c.id = $3\n UNION ALL\n SELECT i.owner_user_id, i.share_permission_id FROM initiative i\n WHERE $2 = 'initiative' AND i.id = $3\n UNION ALL\n SELECT s.owner_id, s.share_permission_id FROM agent_session s\n WHERE $2 = 'agent_session' AND s.id = $3\n UNION ALL\n SELECT d.user_id, d.share_permission_id FROM database_entities d\n WHERE $2 = 'database' AND d.database_id = $3\n )\n SELECT e.owner AS \"owner!\", e.permission_id,\n sp.id AS \"stored_permission_id?\",\n sp.team_share_access_level AS \"level: AccessLevel\",\n sp.team_share_team_id AS team_id, sp.team_share_revision AS \"revision?\",\n tu.team_id AS \"user_team?\", b.team_id AS \"bot_team?\", btu.team_id AS \"bot_user_team?\"\n FROM entity e LEFT JOIN \"SharePermission\" sp ON sp.id = e.permission_id\n LEFT JOIN team_user tu ON tu.user_id = e.owner\n LEFT JOIN bots b ON b.id = CASE\n WHEN e.owner ~ '^bot\\|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$'\n THEN substring(e.owner FROM 5)::uuid END\n LEFT JOIN team_user btu ON btu.user_id = b.owner_user_id", "describe": { "columns": [ { @@ -80,5 +80,5 @@ false ] }, - "hash": "f1ee541b0fd4854bf373d75d224f61f5ba26e0b11150f19d047788668b30f3ed" + "hash": "739a30b2ae7711752ecf7971ba176c02e79b38d8846f5ec782b5967af95c3a84" } diff --git a/.sqlx/query-f0a958d1d92f196a2b6b7d5d75ce322ad79745abe807537a2d8c6c703fd6c7a6.json b/.sqlx/query-e4dd957d19dc07703f1c78cd3e2980d0c4eb86b8723fe7fadfc26c8a8080f5dd.json similarity index 72% rename from .sqlx/query-f0a958d1d92f196a2b6b7d5d75ce322ad79745abe807537a2d8c6c703fd6c7a6.json rename to .sqlx/query-e4dd957d19dc07703f1c78cd3e2980d0c4eb86b8723fe7fadfc26c8a8080f5dd.json index 99f8bd08df8..bcf68208ecf 100644 --- a/.sqlx/query-f0a958d1d92f196a2b6b7d5d75ce322ad79745abe807537a2d8c6c703fd6c7a6.json +++ b/.sqlx/query-e4dd957d19dc07703f1c78cd3e2980d0c4eb86b8723fe7fadfc26c8a8080f5dd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT EXISTS (\n SELECT 1 FROM \"Document\" WHERE $2 = 'document' AND id = $1 AND \"deletedAt\" IS NOT NULL\n UNION ALL\n SELECT 1 FROM \"Project\" WHERE $2 = 'project' AND id = $1 AND \"deletedAt\" IS NOT NULL\n UNION ALL\n SELECT 1 FROM \"Chat\" WHERE $2 = 'chat' AND id = $1 AND \"deletedAt\" IS NOT NULL\n ) AS \"deleted!\"", + "query": "SELECT EXISTS (\n SELECT 1 FROM \"Document\" WHERE $2 = 'document' AND id = $1 AND \"deletedAt\" IS NOT NULL\n UNION ALL\n SELECT 1 FROM \"Project\" WHERE $2 = 'project' AND id = $1 AND \"deletedAt\" IS NOT NULL\n UNION ALL\n SELECT 1 FROM \"Chat\" WHERE $2 = 'chat' AND id = $1 AND \"deletedAt\" IS NOT NULL\n UNION ALL\n SELECT 1 FROM database_entities\n WHERE $2 = 'database' AND database_id::text = $1 AND trashed_at IS NOT NULL\n ) AS \"deleted!\"", "describe": { "columns": [ { @@ -19,5 +19,5 @@ null ] }, - "hash": "f0a958d1d92f196a2b6b7d5d75ce322ad79745abe807537a2d8c6c703fd6c7a6" + "hash": "e4dd957d19dc07703f1c78cd3e2980d0c4eb86b8723fe7fadfc26c8a8080f5dd" } diff --git a/crates/macro_db_client/migrations/20261021183145_database_team_sharing.sql b/crates/macro_db_client/migrations/20261021183145_database_team_sharing.sql new file mode 100644 index 00000000000..cda596b33c7 --- /dev/null +++ b/crates/macro_db_client/migrations/20261021183145_database_team_sharing.sql @@ -0,0 +1,16 @@ +-- A database's team share lives in a SharePermission row, created the first +-- time its owner changes sharing; existing databases keep their access. +ALTER TABLE database_entities + ADD COLUMN share_permission_id TEXT UNIQUE REFERENCES "SharePermission"(id); + +-- Deleting the entity deletes its sharing settings with its storage and grants. +CREATE OR REPLACE FUNCTION delete_database_entity_storage() +RETURNS TRIGGER LANGUAGE plpgsql AS $$ +BEGIN + DELETE FROM databases WHERE id = OLD.database_id; + DELETE FROM database_changes WHERE database_id = OLD.database_id; + DELETE FROM entity_access WHERE entity_type = 'database' AND entity_id = OLD.database_id; + DELETE FROM "SharePermission" WHERE id = OLD.share_permission_id; + RETURN NULL; +END; +$$; diff --git a/crates/share_permission_db_utils/src/team_share.rs b/crates/share_permission_db_utils/src/team_share.rs index 20f75f6dc18..fc59c6d40c3 100644 --- a/crates/share_permission_db_utils/src/team_share.rs +++ b/crates/share_permission_db_utils/src/team_share.rs @@ -103,6 +103,7 @@ fn entity_uuid(entity: &Entity<'_>) -> TeamShareResult { | EntityType::Call | EntityType::AgentSession | EntityType::Initiative + | EntityType::Database ) { return Err(report!(TeamShareError::InvalidEntity)); } @@ -110,7 +111,8 @@ fn entity_uuid(entity: &Entity<'_>) -> TeamShareResult { } /// Load actual owner, current membership and canonical state under the shared guard. -/// Threads and sessions without permissions read as NULL/revision zero without creating rows. +/// Threads, sessions and databases without permissions read as NULL/revision zero +/// without creating rows. /// Tasks and snippets use Document; calls resolve their durable entity. pub async fn load_facts( transaction: &mut Transaction<'_, Postgres>, @@ -151,6 +153,9 @@ async fn load_state(connection: &mut PgConnection, entity: &Entity<'_>) -> TeamS UNION ALL SELECT s.owner_id, s.share_permission_id FROM agent_session s WHERE $2 = 'agent_session' AND s.id = $3 + UNION ALL + SELECT d.user_id, d.share_permission_id FROM database_entities d + WHERE $2 = 'database' AND d.database_id = $3 ) SELECT e.owner AS "owner!", e.permission_id, sp.id AS "stored_permission_id?", @@ -176,7 +181,7 @@ async fn load_state(connection: &mut PgConnection, entity: &Entity<'_>) -> TeamS && (row.permission_id.is_some() || !matches!( entity.entity_type, - EntityType::EmailThread | EntityType::AgentSession + EntityType::EmailThread | EntityType::AgentSession | EntityType::Database )) { return Err(report!(TeamShareError::InvalidState)); @@ -330,6 +335,9 @@ pub async fn restore_cleared( SELECT 1 FROM "Project" WHERE $2 = 'project' AND id = $1 AND "deletedAt" IS NOT NULL UNION ALL SELECT 1 FROM "Chat" WHERE $2 = 'chat' AND id = $1 AND "deletedAt" IS NOT NULL + UNION ALL + SELECT 1 FROM database_entities + WHERE $2 = 'database' AND database_id::text = $1 AND trashed_at IS NOT NULL ) AS "deleted!""#, previous.entity.entity_id.as_ref(), previous.entity.entity_type.as_ref(), diff --git a/crates/share_permission_db_utils/src/team_share/test.rs b/crates/share_permission_db_utils/src/team_share/test.rs index 4cf3aff65df..de4c76ecf74 100644 --- a/crates/share_permission_db_utils/src/team_share/test.rs +++ b/crates/share_permission_db_utils/src/team_share/test.rs @@ -194,6 +194,45 @@ async fn legacy_session_facts_stay_valid_after_permission_initialization( Ok(()) } +#[sqlx::test( + migrator = "MACRO_DB_MIGRATIONS", + fixtures(path = "../../fixtures", scripts("team_share")) +)] +async fn database_without_permissions_is_shared_once_its_permission_exists( + pool: PgPool, +) -> rootcause::Result<()> { + let entity = + EntityType::Database.with_entity_string("20000000-0000-0000-0000-000000000011".to_string()); + let mut tx = pool.begin().await?; + sqlx::query!("INSERT INTO databases (id) VALUES ('20000000-0000-0000-0000-000000000011')") + .execute(tx.as_mut()) + .await?; + sqlx::query!("INSERT INTO database_entities (database_id, name, user_id) VALUES ('20000000-0000-0000-0000-000000000011', 'Database', 'macro|owner@example.com')") + .execute(tx.as_mut()) + .await?; + let facts = load_facts(&mut tx, &entity).await?; + assert_eq!(facts.current, None); + assert_eq!(facts.revision, 0); + let authorized = command(&facts, Some(AccessLevel::Edit)); + + sqlx::query!(r#"INSERT INTO "SharePermission" (id) VALUES ('database')"#) + .execute(tx.as_mut()) + .await?; + sqlx::query!("UPDATE database_entities SET share_permission_id = 'database' WHERE database_id = '20000000-0000-0000-0000-000000000011'") + .execute(tx.as_mut()) + .await?; + apply(&mut tx, &authorized).await?; + assert_eq!( + load_facts(&mut tx, &entity).await?.current, + Some(TeamShareGrant { + team_id: Uuid::parse_str("10000000-0000-0000-0000-000000000001")?, + level: TeamShareLevel::Edit, + }) + ); + + apply_comment_view_and_clear(&mut tx, &entity).await +} + #[sqlx::test( migrator = "MACRO_DB_MIGRATIONS", fixtures(path = "../../fixtures", scripts("team_share")) From 6e6409a7c519c3b8b2f52e45d203db6af61bda30 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 22 Oct 2026 21:29:07 +0000 Subject: [PATCH 2/7] feat(databases): share a database with the owner's team Why: owners on a team had no way to give their teammates access to a database short of sharing it person by person or channel by channel. What: - The sharing service reads and writes `teamShareAccessLevel` alongside channel grants, in one transaction. - Only the actual owner may change the team share; an owner with no team gets a clear error, and a share that changed since authorization returns 409. - Teammates reach the database through a team-source `entity_access` grant, covered by a real-Postgres test. --- ...8a1d77b3fb111c59186eaf8300d4fc6c2403b.json | 22 ++ ...4fd5540f3e1a6e36d343500b7f70bb007401b.json | 15 + ...c0c8f3cbbc0d6bffcc3eac9864030486fa61c.json | 34 +++ Cargo.lock | 2 + crates/databases/AGENTS.md | 8 +- crates/databases/Cargo.toml | 3 + .../src/domain/models/schema_error.rs | 12 +- .../databases/src/domain/service/sharing.rs | 60 +++- .../src/domain/service/test/fakes.rs | 7 + .../src/domain/service/test/sharing.rs | 218 ++++++++++++-- crates/databases/src/domain/sharing.rs | 46 ++- crates/databases/src/inbound/axum_router.rs | 6 +- .../src/inbound/axum_router/sharing.rs | 13 +- .../src/outbound/pg_databases_repo.rs | 3 + .../src/outbound/pg_databases_repo/sharing.rs | 98 ++++++- .../src/outbound/pg_databases_repo/test.rs | 2 + .../pg_databases_repo/test/sharing.rs | 40 +-- .../pg_databases_repo/test/team_share.rs | 268 ++++++++++++++++++ .../pg_access_repo/queries/database_access.rs | 5 +- 19 files changed, 777 insertions(+), 85 deletions(-) create mode 100644 .sqlx/query-37d8d63b28d3c57a726897d59a38a1d77b3fb111c59186eaf8300d4fc6c2403b.json create mode 100644 .sqlx/query-58d824f76ec5f0dbb0a6607c2574fd5540f3e1a6e36d343500b7f70bb007401b.json create mode 100644 .sqlx/query-ad741c642b2f5d0f04e79ceaf64c0c8f3cbbc0d6bffcc3eac9864030486fa61c.json create mode 100644 crates/databases/src/outbound/pg_databases_repo/test/team_share.rs diff --git a/.sqlx/query-37d8d63b28d3c57a726897d59a38a1d77b3fb111c59186eaf8300d4fc6c2403b.json b/.sqlx/query-37d8d63b28d3c57a726897d59a38a1d77b3fb111c59186eaf8300d4fc6c2403b.json new file mode 100644 index 00000000000..58a2217d984 --- /dev/null +++ b/.sqlx/query-37d8d63b28d3c57a726897d59a38a1d77b3fb111c59186eaf8300d4fc6c2403b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT share_permission_id FROM database_entities WHERE database_id = $1 AND trashed_at IS NULL FOR UPDATE", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "share_permission_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "37d8d63b28d3c57a726897d59a38a1d77b3fb111c59186eaf8300d4fc6c2403b" +} diff --git a/.sqlx/query-58d824f76ec5f0dbb0a6607c2574fd5540f3e1a6e36d343500b7f70bb007401b.json b/.sqlx/query-58d824f76ec5f0dbb0a6607c2574fd5540f3e1a6e36d343500b7f70bb007401b.json new file mode 100644 index 00000000000..a75a723e93c --- /dev/null +++ b/.sqlx/query-58d824f76ec5f0dbb0a6607c2574fd5540f3e1a6e36d343500b7f70bb007401b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE database_entities SET share_permission_id = $2 WHERE database_id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Text" + ] + }, + "nullable": [] + }, + "hash": "58d824f76ec5f0dbb0a6607c2574fd5540f3e1a6e36d343500b7f70bb007401b" +} diff --git a/.sqlx/query-ad741c642b2f5d0f04e79ceaf64c0c8f3cbbc0d6bffcc3eac9864030486fa61c.json b/.sqlx/query-ad741c642b2f5d0f04e79ceaf64c0c8f3cbbc0d6bffcc3eac9864030486fa61c.json new file mode 100644 index 00000000000..66e5f67dc1f --- /dev/null +++ b/.sqlx/query-ad741c642b2f5d0f04e79ceaf64c0c8f3cbbc0d6bffcc3eac9864030486fa61c.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT sp.team_share_access_level AS \"level: AccessLevel\"\n FROM database_entities d\n JOIN \"SharePermission\" sp ON sp.id = d.share_permission_id\n WHERE d.database_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "level: AccessLevel", + "type_info": { + "Custom": { + "name": "\"AccessLevel\"", + "kind": { + "Enum": [ + "view", + "comment", + "edit", + "owner" + ] + } + } + } + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + true + ] + }, + "hash": "ad741c642b2f5d0f04e79ceaf64c0c8f3cbbc0d6bffcc3eac9864030486fa61c" +} diff --git a/Cargo.lock b/Cargo.lock index 0e49334e0eb..ee077892225 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5903,6 +5903,7 @@ dependencies = [ "macro_uuid", "model-entity", "model-error-response", + "model_owner", "models_databases", "models_permissions", "models_properties", @@ -5912,6 +5913,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", + "share_permission_db_utils", "sqlx", "strum 0.27.2", "thiserror 2.0.18", diff --git a/crates/databases/AGENTS.md b/crates/databases/AGENTS.md index 74f1cc01caa..129432e42b9 100644 --- a/crates/databases/AGENTS.md +++ b/crates/databases/AGENTS.md @@ -26,7 +26,8 @@ service with Axum, agent-tool and Postgres adapters. ## Boundaries -- Depends on: `entity_access` (permission scoping), `properties` (cell values), +- Depends on: `entity_access` (permission scoping), `entity_access_db_utils` and + `share_permission_db_utils` (channel grants and the team share), `properties` (cell values), `macro_event_broker`/`macro_event_topics` (change notifications), `connection_gateway_client` (liveness), `entity_mutation` (rename/trash/restore). - Used by: `databases_sql` (agent SQL), `crm` and `forms` (own storage via @@ -55,6 +56,11 @@ service with Axum, agent-tool and Postgres adapters. other (see README "Storage and app entities"). - Column type changes must convert every value or refuse with the misfit count; never silently drop data outside an explicit delete. +- Sharing (`src/domain/sharing.rs`): channel grants and a team share; no share + link. The team share's state lives in a `SharePermission` row that + `database_entities.share_permission_id` points at, made the first time the + owner sets it, and its grant is a team-source `entity_access` row that rows, + listing and Soup read like any other. Only the actual owner may change it. ## Related diff --git a/crates/databases/Cargo.toml b/crates/databases/Cargo.toml index dc639e4ce2a..336a42e14eb 100644 --- a/crates/databases/Cargo.toml +++ b/crates/databases/Cargo.toml @@ -39,6 +39,7 @@ postgres = [ "dep:anyhow", "dep:entity_access_db_utils", "dep:properties", + "dep:share_permission_db_utils", "dep:sqlx", "outbound", ] @@ -72,6 +73,7 @@ schemars = { workspace = true, optional = true } serde = { workspace = true } serde_json = { workspace = true } sha2 = { workspace = true } +share_permission_db_utils = { path = "../share_permission_db_utils", optional = true } sqlx = { workspace = true, optional = true } strum = { workspace = true } thiserror = { workspace = true } @@ -85,6 +87,7 @@ workspace-hack = { version = "0.1", path = "../workspace-hack" } [dev-dependencies] entity_access = { path = "../entity_access", default-features = false, features = ["outbound"] } macro_db_migrator = { path = "../macro_db_migrator" } +model_owner = { path = "../model_owner" } # The palette the fakes colour new options from. properties = { path = "../properties", default-features = false } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/crates/databases/src/domain/models/schema_error.rs b/crates/databases/src/domain/models/schema_error.rs index dd02e6c7e46..19d4615fd85 100644 --- a/crates/databases/src/domain/models/schema_error.rs +++ b/crates/databases/src/domain/models/schema_error.rs @@ -205,9 +205,15 @@ pub enum SharingError { /// Share links are not supported for databases. #[error("Databases cannot be shared by link yet.")] LinkShare, - /// Team shares are not supported for databases. - #[error("Databases cannot be shared with a team yet.")] - TeamShare, + /// The owner is not on a team to share with. + #[error("Join a team to share this database with it.")] + NoTeam, + /// A team share must grant view, comment, or edit access. + #[error("Share with your team for view, comment, or edit access.")] + InvalidTeamLevel, + /// Ownership, team membership, or the team share changed meanwhile. + #[error("This database's sharing changed. Refresh and try again.")] + Changed, /// A request changes more channel grants than any may. #[error("Share with at most {max} channels at a time.")] TooManyChannels { diff --git a/crates/databases/src/domain/service/sharing.rs b/crates/databases/src/domain/service/sharing.rs index 4c6a15f6eda..d4e529ca2ec 100644 --- a/crates/databases/src/domain/service/sharing.rs +++ b/crates/databases/src/domain/service/sharing.rs @@ -1,8 +1,10 @@ use super::*; -use crate::domain::sharing::{DatabaseSharingRepo, DatabaseSharingService}; +use crate::domain::sharing::{DatabaseSharingRepo, DatabaseSharingService, SharingWrite}; use models_permissions::share_permission::{ SharePermissionV2, UpdateSharePermissionRequestV2, - access_level::AccessLevel as ShareAccessLevel, channel_share_permission::UpdateOperation, + access_level::AccessLevel as ShareAccessLevel, + channel_share_permission::UpdateOperation, + team_share::{TeamShareLevel, TeamSharePolicyError, TeamShareRequest, authorize_team_share}, }; /// Most channel grants one request may change. @@ -32,11 +34,16 @@ where .channel_grants(database.id) .await .map_err(repository_error)?; + let team_share_access_level = self + .repository + .team_share_level(database.id) + .await + .map_err(repository_error)?; Ok(SharePermissionV2 { id: database.id.to_string(), link_share: None, link_share_access_level: None, - team_share_access_level: None, + team_share_access_level, owner: database.owner_id, channel_share_permissions: Some(channel_share_permissions), }) @@ -53,9 +60,6 @@ where { return Err(DatabaseError::from(SharingError::LinkShare)); } - if matches!(request.team_share_access_level, Some(Some(_))) { - return Err(DatabaseError::from(SharingError::TeamShare)); - } let grants = request.channel_share_permissions.unwrap_or_default(); if grants.len() > MAX_CHANNEL_GRANTS_PER_UPDATE { return Err(SharingError::TooManyChannels { @@ -77,15 +81,37 @@ where if database.trashed_at.is_some() { return Err(DatabaseError::NotFound); } - if !self + let team_share = match request.team_share_access_level { + None => None, + access_level => { + let facts = self + .repository + .team_share_facts(database.id) + .await + .map_err(repository_error)?; + authorize_team_share( + receipt.acting_user_id(), + &facts, + TeamShareRequest { + access_level, + legacy_enabled: None, + }, + TeamShareLevel::Edit, + ) + .map_err(team_share_refusal)? + } + }; + match self .repository - .update_channel_grants(database.id, &grants) + .update_sharing(database.id, &grants, team_share.as_ref()) .await .map_err(repository_error)? { - return Err(DatabaseError::NotFound); + SharingWrite::Written => {} + SharingWrite::Gone => return Err(DatabaseError::NotFound), + SharingWrite::Changed => return Err(SharingError::Changed.into()), } - if !grants.is_empty() { + if !grants.is_empty() || team_share.is_some() { self.emit(DatabaseMacroEvent::sharing_changed( events::DatabaseSharingChangedMetadata { database_id: database.id, @@ -96,3 +122,17 @@ where self.share_permissions(receipt).await } } + +/// A team share the policy refused, as the caller sees it. +fn team_share_refusal(error: TeamSharePolicyError) -> DatabaseError { + match error { + TeamSharePolicyError::MissingActor | TeamSharePolicyError::NotOwner => { + DatabaseError::Unauthorized + } + TeamSharePolicyError::MissingTeam => SharingError::NoTeam.into(), + TeamSharePolicyError::InvalidLevel | TeamSharePolicyError::ContradictoryInputs => { + SharingError::InvalidTeamLevel.into() + } + TeamSharePolicyError::InvalidRevision => repository_error(error), + } +} diff --git a/crates/databases/src/domain/service/test/fakes.rs b/crates/databases/src/domain/service/test/fakes.rs index 7b34eb95d7e..753528e569e 100644 --- a/crates/databases/src/domain/service/test/fakes.rs +++ b/crates/databases/src/domain/service/test/fakes.rs @@ -31,6 +31,13 @@ pub(super) struct World { /// Simulate the gateway refusing an awareness relay. pub(super) awareness_relay_fails: bool, pub(super) share_updates: Vec>, + /// The owner's team, which a team share goes to. + pub(super) owner_team: Option, + /// The team share and its revision. + pub(super) team_share: Option, + pub(super) team_share_revision: i64, + /// Simulate the team facts changing between authorization and the write. + pub(super) team_share_goes_stale: bool, /// Every `macro.databases` envelope the service handed the broker. pub(super) broker_events: Vec, /// Every `settle_inference` call, newest last. diff --git a/crates/databases/src/domain/service/test/sharing.rs b/crates/databases/src/domain/service/test/sharing.rs index 6563ee3c373..76c7d71e856 100644 --- a/crates/databases/src/domain/service/test/sharing.rs +++ b/crates/databases/src/domain/service/test/sharing.rs @@ -1,10 +1,17 @@ use super::*; -use crate::domain::sharing::{DatabaseSharingRepo, DatabaseSharingService}; +use crate::domain::sharing::{DatabaseSharingRepo, DatabaseSharingService, SharingWrite}; +use model_owner::Owner; +use models_permissions::share_permission::access_level::AccessLevel as ShareAccessLevel; use models_permissions::share_permission::channel_share_permission::{ ChannelSharePermission, UpdateChannelSharePermission, UpdateOperation, }; +use models_permissions::share_permission::team_share::{ + AuthorizedTeamShareCommand, TeamShareFacts, +}; use models_permissions::share_permission::{LinkShare, UpdateSharePermissionRequestV2}; +const TEAM: Uuid = Uuid::from_u128(0x0199a000_0000_7000_8000_00000000aa01); + impl DatabaseSharingRepo for FakeRepo { type Error = FakeError; async fn channel_grants( @@ -13,13 +20,68 @@ impl DatabaseSharingRepo for FakeRepo { ) -> Result, Self::Error> { Ok(vec![]) } - async fn update_channel_grants( + async fn team_share_level( &self, _: DatabaseId, + ) -> Result, Self::Error> { + Ok(self + .0 + .lock() + .unwrap() + .team_share + .map(|grant| grant.level.into())) + } + async fn team_share_facts( + &self, + database_id: DatabaseId, + ) -> Result { + let world = self.0.lock().unwrap(); + let database = world + .databases + .iter() + .find(|database| database.id == database_id) + .ok_or(FakeError)?; + Ok(TeamShareFacts { + entity: EntityType::Database.with_entity_string(database_id.to_string()), + owner: Owner::from_principal_str(&database.owner_id).map_err(|_| FakeError)?, + owner_team_id: world.owner_team, + current: world.team_share, + revision: world.team_share_revision, + }) + } + async fn update_sharing( + &self, + database_id: DatabaseId, grants: &[UpdateChannelSharePermission], - ) -> Result { - self.0.lock().unwrap().share_updates.push(grants.to_vec()); - Ok(true) + team_share: Option<&AuthorizedTeamShareCommand>, + ) -> Result { + let mut world = self.0.lock().unwrap(); + if world + .databases + .iter() + .any(|database| database.id == database_id && database.trashed_at.is_some()) + { + return Ok(SharingWrite::Gone); + } + if let Some(command) = team_share { + if world.team_share_goes_stale { + return Ok(SharingWrite::Changed); + } + world.team_share = command.target(); + world.team_share_revision = command.next_revision(); + } + world.share_updates.push(grants.to_vec()); + Ok(SharingWrite::Written) + } +} + +/// Changes only the team share. +fn team_share(level: Option) -> UpdateSharePermissionRequestV2 { + UpdateSharePermissionRequestV2 { + link_share: None, + link_share_access_level: None, + team_share_access_level: Some(level), + channel_share_permissions: None, } } @@ -99,7 +161,7 @@ async fn invalid_share_requests_are_rejected_before_persistence() { } #[tokio::test] -async fn link_and_team_sharing_read_as_off_and_cannot_be_turned_on() { +async fn link_sharing_reads_as_off_and_cannot_be_turned_on() { let seeded = seeded().await; let (world, svc, database_id) = (seeded.world, seeded.service, seeded.database_id); @@ -136,26 +198,6 @@ async fn link_and_team_sharing_read_as_off_and_cannot_be_turned_on() { ), "{link:?}" ); - let team = svc - .update_share_permissions( - receipt(database_id, OWNER, AccessLevel::Owner), - UpdateSharePermissionRequestV2 { - link_share: None, - link_share_access_level: None, - team_share_access_level: Some(Some( - models_permissions::share_permission::access_level::AccessLevel::Edit, - )), - channel_share_permissions: None, - }, - ) - .await; - assert!( - matches!( - &team, - Err(DatabaseError::InvalidSharing(SharingError::TeamShare)) - ), - "{team:?}" - ); // Turning off what is already off is a no-op, not a refusal. let unchanged = svc @@ -174,6 +216,130 @@ async fn link_and_team_sharing_read_as_off_and_cannot_be_turned_on() { assert_eq!(world.lock().unwrap().share_updates, vec![Vec::new()]); } +#[tokio::test] +async fn the_owner_shares_a_database_with_their_team_and_stops_sharing_it() { + let seeded = seeded().await; + let (world, svc, database_id) = (seeded.world, seeded.service, seeded.database_id); + world.lock().unwrap().owner_team = Some(TEAM); + world.lock().unwrap().broker_events.clear(); + + let shared = svc + .update_share_permissions( + receipt(database_id, OWNER, AccessLevel::Owner), + team_share(Some(ShareAccessLevel::Edit)), + ) + .await + .unwrap(); + assert_eq!(shared.team_share_access_level, Some(ShareAccessLevel::Edit)); + assert_eq!( + svc.share_permissions(receipt(database_id, OWNER, AccessLevel::Owner)) + .await + .unwrap() + .team_share_access_level, + Some(ShareAccessLevel::Edit) + ); + assert_eq!( + world.lock().unwrap().team_share, + Some( + models_permissions::share_permission::team_share::TeamShareGrant { + team_id: TEAM, + level: models_permissions::share_permission::team_share::TeamShareLevel::Edit, + } + ) + ); + assert_eq!(world.lock().unwrap().broker_events.len(), 1); + + let downgraded = svc + .update_share_permissions( + receipt(database_id, OWNER, AccessLevel::Owner), + team_share(Some(ShareAccessLevel::View)), + ) + .await + .unwrap(); + assert_eq!( + downgraded.team_share_access_level, + Some(ShareAccessLevel::View) + ); + + let unshared = svc + .update_share_permissions( + receipt(database_id, OWNER, AccessLevel::Owner), + team_share(None), + ) + .await + .unwrap(); + assert_eq!(unshared.team_share_access_level, None); + assert_eq!(world.lock().unwrap().team_share, None); + assert_eq!(world.lock().unwrap().team_share_revision, 3); + assert_eq!(world.lock().unwrap().broker_events.len(), 3); +} + +#[tokio::test] +async fn team_shares_are_refused_unless_the_owner_can_share_with_a_team() { + let seeded = seeded().await; + let (world, svc, database_id) = (seeded.world, seeded.service, seeded.database_id); + + let no_team = svc + .update_share_permissions( + receipt(database_id, OWNER, AccessLevel::Owner), + team_share(Some(ShareAccessLevel::Edit)), + ) + .await; + assert!( + matches!( + &no_team, + Err(DatabaseError::InvalidSharing(SharingError::NoTeam)) + ), + "{no_team:?}" + ); + + world.lock().unwrap().owner_team = Some(TEAM); + let ownership = svc + .update_share_permissions( + receipt(database_id, OWNER, AccessLevel::Owner), + team_share(Some(ShareAccessLevel::Owner)), + ) + .await; + assert!( + matches!( + &ownership, + Err(DatabaseError::InvalidSharing( + SharingError::InvalidTeamLevel + )) + ), + "{ownership:?}" + ); + + // Owner access through a grant is not ownership. + let not_owner = svc + .update_share_permissions( + receipt(database_id, VIEWER, AccessLevel::Owner), + team_share(Some(ShareAccessLevel::Edit)), + ) + .await; + assert!( + matches!(¬_owner, Err(DatabaseError::Unauthorized)), + "{not_owner:?}" + ); + + world.lock().unwrap().team_share_goes_stale = true; + let stale = svc + .update_share_permissions( + receipt(database_id, OWNER, AccessLevel::Owner), + team_share(Some(ShareAccessLevel::Edit)), + ) + .await; + assert!( + matches!( + &stale, + Err(DatabaseError::InvalidSharing(SharingError::Changed)) + ), + "{stale:?}" + ); + assert_eq!(world.lock().unwrap().team_share, None); + assert!(world.lock().unwrap().share_updates.is_empty()); +} + #[tokio::test] async fn sharing_changes_publish_a_sharing_changed_event() { let seeded = seeded().await; diff --git a/crates/databases/src/domain/sharing.rs b/crates/databases/src/domain/sharing.rs index 7d6b54dce36..0f3f03742e7 100644 --- a/crates/databases/src/domain/sharing.rs +++ b/crates/databases/src/domain/sharing.rs @@ -1,15 +1,33 @@ -//! Sharing by explicit channel grants over `SharePermissionV2`; databases -//! have no share link or team share, and refuse turning either on. +//! Sharing over `SharePermissionV2`: explicit channel grants and a team share +//! with the owner's team, both through the canonical entity-access writers. +//! Databases have no share link and refuse turning one on. use entity_access::domain::models::{EntityAccessReceipt, OwnerAccessLevel}; +use models_permissions::share_permission::access_level::AccessLevel; use models_permissions::share_permission::channel_share_permission::{ ChannelSharePermission, UpdateChannelSharePermission, }; +use models_permissions::share_permission::team_share::{ + AuthorizedTeamShareCommand, TeamShareFacts, +}; use models_permissions::share_permission::{SharePermissionV2, UpdateSharePermissionRequestV2}; use super::models::{DatabaseError, DatabaseId}; -/// Persistence of direct channel grants, implemented through the owning access crate. +/// What a sharing write did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SharingWrite { + /// The grants and team share were written. + Written, + /// The database is trashed or gone; nothing was written. + Gone, + /// Ownership, team membership or the team share changed since the + /// change was authorized; nothing was written. + Changed, +} + +/// Persistence of channel grants and the team share, implemented through +/// the owning access crates. pub trait DatabaseSharingRepo: Send + Sync + 'static { /// Persistence failure. type Error: std::error::Error + Send + Sync + 'static; @@ -20,12 +38,26 @@ pub trait DatabaseSharingRepo: Send + Sync + 'static { database_id: DatabaseId, ) -> impl Future, Self::Error>> + Send; - /// Change channel grants only while the database remains live. - fn update_channel_grants( + /// The level the database is shared with its owner's team at, if it is. + fn team_share_level( + &self, + database_id: DatabaseId, + ) -> impl Future, Self::Error>> + Send; + + /// The actual owner, their team and the team share, for authorizing a change. + fn team_share_facts( + &self, + database_id: DatabaseId, + ) -> impl Future> + Send; + + /// Change channel grants and the team share together, only while the + /// database remains live and the team facts are unchanged. + fn update_sharing( &self, database_id: DatabaseId, grants: &[UpdateChannelSharePermission], - ) -> impl Future> + Send; + team_share: Option<&AuthorizedTeamShareCommand>, + ) -> impl Future> + Send; } /// Owners control the database's recipients and each recipient's access level. @@ -36,7 +68,7 @@ pub trait DatabaseSharingService: Send + Sync + 'static { receipt: EntityAccessReceipt, ) -> impl Future> + Send; - /// Update explicit channel grants without modifying ownership. + /// Update channel grants and the team share without modifying ownership. fn update_share_permissions( &self, receipt: EntityAccessReceipt, diff --git a/crates/databases/src/inbound/axum_router.rs b/crates/databases/src/inbound/axum_router.rs index 8873fc30501..9392f536a4a 100644 --- a/crates/databases/src/inbound/axum_router.rs +++ b/crates/databases/src/inbound/axum_router.rs @@ -46,7 +46,8 @@ use serde::{Deserialize, Serialize}; use crate::domain::models::{ Awareness, ColumnId, CreateDatabase, Database, DatabaseDetail, DatabaseError, InferColumnType, - InferColumnTypeOutcome, ListedDatabase, SavedQueryError, Table, TableVersion, Viewer, + InferColumnTypeOutcome, ListedDatabase, SavedQueryError, SharingError, Table, TableVersion, + Viewer, }; use crate::domain::ports::DatabasesService; use ops::OpRefusalResponse; @@ -416,7 +417,8 @@ impl IntoResponse for DatabaseError { DatabaseError::Unauthorized => StatusCode::FORBIDDEN, DatabaseError::VersionConflict | DatabaseError::OptionInUse - | DatabaseError::RowInUse => StatusCode::CONFLICT, + | DatabaseError::RowInUse + | DatabaseError::InvalidSharing(SharingError::Changed) => StatusCode::CONFLICT, DatabaseError::InvalidSchemaOperation(_) | DatabaseError::InvalidSharing(_) | DatabaseError::InvalidOp(_) => StatusCode::BAD_REQUEST, diff --git a/crates/databases/src/inbound/axum_router/sharing.rs b/crates/databases/src/inbound/axum_router/sharing.rs index c71048467c8..0fb5e274710 100644 --- a/crates/databases/src/inbound/axum_router/sharing.rs +++ b/crates/databases/src/inbound/axum_router/sharing.rs @@ -5,8 +5,8 @@ use crate::domain::sharing::DatabaseSharingService; use entity_access::domain::models::OwnerAccessLevel; use models_permissions::share_permission::{SharePermissionV2, UpdateSharePermissionRequestV2}; -/// Read recipients for a database owned by the caller. Link and team -/// sharing are always `null`: databases do not support them yet. +/// Read recipients and the team share for a database owned by the caller. +/// Link sharing is always `null`: databases do not support it. #[utoipa::path(get, tag = "databases", operation_id = "get_database_permissions", path = "/databases/{id}/permissions", params(("id" = Uuid, Path)), responses((status = 200, body = SharePermissionV2), (status = 401, body = ErrorResponse), @@ -28,14 +28,17 @@ where .map(Json) } -/// Update channel recipients after proving database ownership. Turning on -/// link or team sharing is refused; ownership cannot be changed here. +/// Update channel recipients and the team share after proving database +/// ownership. Turning on link sharing is refused; ownership cannot be +/// changed here. Only the actual owner may change the team share, and a 409 +/// means sharing changed since the request was authorized. #[utoipa::path(patch, tag = "databases", operation_id = "update_database_permissions", path = "/databases/{id}/permissions", params(("id" = Uuid, Path)), request_body = UpdateSharePermissionRequestV2, responses((status = 200, body = SharePermissionV2), (status = 400, body = ErrorResponse), (status = 401, body = ErrorResponse), (status = 403, body = ErrorResponse), - (status = 404, body = ErrorResponse), (status = 500, body = ErrorResponse)))] + (status = 404, body = ErrorResponse), (status = 409, body = ErrorResponse), + (status = 500, body = ErrorResponse)))] #[tracing::instrument(err, skip_all)] pub async fn update_permissions_handler( access: DatabaseAccessLevelExtractor, diff --git a/crates/databases/src/outbound/pg_databases_repo.rs b/crates/databases/src/outbound/pg_databases_repo.rs index 85b4891baf9..1fe87a4ccb3 100644 --- a/crates/databases/src/outbound/pg_databases_repo.rs +++ b/crates/databases/src/outbound/pg_databases_repo.rs @@ -66,6 +66,9 @@ pub enum PgDatabasesRepoError { /// This build cannot interpret the persisted journal payload format. #[error("unsupported database journal payload version {0}")] UnsupportedJournalPayloadVersion(i32), + /// The canonical team-share writer failed. + #[error("team share: {0}")] + TeamShare(rootcause::Report), } /// The UUIDs of typed ids, for a statement's `ANY($n)`. diff --git a/crates/databases/src/outbound/pg_databases_repo/sharing.rs b/crates/databases/src/outbound/pg_databases_repo/sharing.rs index 34688db03a9..344dcdebbdd 100644 --- a/crates/databases/src/outbound/pg_databases_repo/sharing.rs +++ b/crates/databases/src/outbound/pg_databases_repo/sharing.rs @@ -1,9 +1,17 @@ use super::{PgDatabasesRepo, PgDatabasesRepoError}; -use crate::domain::{models::DatabaseId, sharing::DatabaseSharingRepo}; +use crate::domain::{ + models::DatabaseId, + sharing::{DatabaseSharingRepo, SharingWrite}, +}; use model_entity::EntityType; +use models_permissions::share_permission::access_level::AccessLevel; use models_permissions::share_permission::channel_share_permission::{ ChannelSharePermission, UpdateChannelSharePermission, }; +use models_permissions::share_permission::team_share::{ + AuthorizedTeamShareCommand, TeamShareFacts, +}; +use share_permission_db_utils::team_share::{self, TeamShareError}; impl DatabaseSharingRepo for PgDatabasesRepo { type Error = PgDatabasesRepoError; @@ -21,23 +29,59 @@ impl DatabaseSharingRepo for PgDatabasesRepo< .await?) } - #[tracing::instrument(err, skip(self, grants))] - async fn update_channel_grants( + #[tracing::instrument(err, skip(self))] + async fn team_share_level( + &self, + database_id: DatabaseId, + ) -> Result, Self::Error> { + Ok(sqlx::query_scalar!( + r#"SELECT sp.team_share_access_level AS "level: AccessLevel" + FROM database_entities d + JOIN "SharePermission" sp ON sp.id = d.share_permission_id + WHERE d.database_id = $1"#, + database_id.into_uuid(), + ) + .fetch_optional(&self.pool) + .await? + .flatten()) + } + + #[tracing::instrument(err, skip(self))] + async fn team_share_facts( + &self, + database_id: DatabaseId, + ) -> Result { + let mut transaction = self.pool.begin().await?; + let entity = EntityType::Database.with_entity_string(database_id.to_string()); + let facts = team_share::load_facts(&mut transaction, &entity) + .await + .map_err(PgDatabasesRepoError::TeamShare)?; + transaction.commit().await?; + Ok(facts) + } + + #[tracing::instrument(err, skip(self, grants, team_share))] + async fn update_sharing( &self, database_id: DatabaseId, grants: &[UpdateChannelSharePermission], - ) -> Result { + team_share: Option<&AuthorizedTeamShareCommand>, + ) -> Result { let mut transaction = self.pool.begin().await?; - let live = sqlx::query_scalar!( - "SELECT database_id FROM database_entities WHERE database_id = $1 AND trashed_at IS NULL FOR SHARE", + // The team-share guard comes before any row lock, as its writer requires. + if team_share.is_some() { + team_share::acquire_guard(&mut transaction).await?; + } + let Some(row) = sqlx::query!( + "SELECT share_permission_id FROM database_entities WHERE database_id = $1 AND trashed_at IS NULL FOR UPDATE", database_id.into_uuid(), ) .fetch_optional(&mut *transaction) - .await?; - if live.is_none() { + .await? + else { transaction.rollback().await?; - return Ok(false); - } + return Ok(SharingWrite::Gone); + }; entity_access_db_utils::update_entity_access_channel_share_permissions( &mut transaction, database_id.as_uuid(), @@ -45,7 +89,39 @@ impl DatabaseSharingRepo for PgDatabasesRepo< grants, ) .await?; + if let Some(command) = team_share { + // The team share's state lives in a SharePermission row, made + // the first time it is set. + if row.share_permission_id.is_none() { + let permission_id = macro_uuid::generate_uuid_v7().to_string(); + share_permission_db_utils::create_private_share_permission( + &mut *transaction, + &permission_id, + ) + .await?; + sqlx::query!( + "UPDATE database_entities SET share_permission_id = $2 WHERE database_id = $1", + database_id.into_uuid(), + permission_id, + ) + .execute(&mut *transaction) + .await?; + } + match team_share::apply(&mut transaction, command).await { + Ok(()) => {} + Err(error) + if matches!( + error.current_context(), + TeamShareError::ChangedFacts | TeamShareError::UntrackedGrant + ) => + { + transaction.rollback().await?; + return Ok(SharingWrite::Changed); + } + Err(error) => return Err(PgDatabasesRepoError::TeamShare(error)), + } + } transaction.commit().await?; - Ok(true) + Ok(SharingWrite::Written) } } diff --git a/crates/databases/src/outbound/pg_databases_repo/test.rs b/crates/databases/src/outbound/pg_databases_repo/test.rs index 47fbd8aaccc..4a66548d553 100644 --- a/crates/databases/src/outbound/pg_databases_repo/test.rs +++ b/crates/databases/src/outbound/pg_databases_repo/test.rs @@ -30,6 +30,8 @@ mod schema_ops; mod sharing; #[cfg(feature = "gateway")] mod tables; +#[cfg(feature = "gateway")] +mod team_share; mod transfer; #[cfg(feature = "gateway")] mod undo; diff --git a/crates/databases/src/outbound/pg_databases_repo/test/sharing.rs b/crates/databases/src/outbound/pg_databases_repo/test/sharing.rs index 6d0d9d7ea10..d58e90b19f4 100644 --- a/crates/databases/src/outbound/pg_databases_repo/test/sharing.rs +++ b/crates/databases/src/outbound/pg_databases_repo/test/sharing.rs @@ -1,5 +1,5 @@ use super::*; -use crate::domain::sharing::DatabaseSharingRepo; +use crate::domain::sharing::{DatabaseSharingRepo, SharingWrite}; use models_permissions::share_permission::channel_share_permission::{ UpdateChannelSharePermission, UpdateOperation, }; @@ -13,17 +13,19 @@ async fn database_channel_grants_can_be_changed_and_revoked_without_touching_own (UpdateOperation::Replace, Some(AccessLevel::Edit)), (UpdateOperation::Remove, None), ] { - assert!( - repo.update_channel_grants( + assert_eq!( + repo.update_sharing( table.database_id, &[UpdateChannelSharePermission { channel_id: channel_id.clone(), operation, access_level: level, - }] + }], + None, ) .await - .unwrap() + .unwrap(), + SharingWrite::Written ); let grants = repo.channel_grants(table.database_id).await.unwrap(); assert_eq!(grants.first().map(|grant| grant.access_level), level); @@ -33,18 +35,19 @@ async fn database_channel_grants_can_be_changed_and_revoked_without_touching_own repo.trash_database(table.database_id, chrono::Utc::now()) .await .unwrap(); - assert!( - !repo - .update_channel_grants( - table.database_id, - &[UpdateChannelSharePermission { - channel_id, - operation: UpdateOperation::Add, - access_level: Some(AccessLevel::View), - }] - ) - .await - .unwrap() + assert_eq!( + repo.update_sharing( + table.database_id, + &[UpdateChannelSharePermission { + channel_id, + operation: UpdateOperation::Add, + access_level: Some(AccessLevel::View), + }], + None, + ) + .await + .unwrap(), + SharingWrite::Gone ); assert!( repo.channel_grants(table.database_id) @@ -58,13 +61,14 @@ async fn database_channel_grants_can_be_changed_and_revoked_without_touching_own async fn sharing_a_reference_does_not_downgrade_existing_channel_access(pool: PgPool) { let (repo, table, _) = fixture(&pool).await; let channel_id = macro_uuid::generate_uuid_v7(); - repo.update_channel_grants( + repo.update_sharing( table.database_id, &[UpdateChannelSharePermission { channel_id: channel_id.to_string(), operation: UpdateOperation::Add, access_level: Some(AccessLevel::Edit), }], + None, ) .await .unwrap(); diff --git a/crates/databases/src/outbound/pg_databases_repo/test/team_share.rs b/crates/databases/src/outbound/pg_databases_repo/test/team_share.rs new file mode 100644 index 00000000000..bc55934a3bd --- /dev/null +++ b/crates/databases/src/outbound/pg_databases_repo/test/team_share.rs @@ -0,0 +1,268 @@ +//! A database shared with its owner's team, read and written by a teammate +//! through the service, access checks and listing that hosts use. The +//! source-id cache is process-wide, so the test mints its own user ids. + +use std::sync::Arc; + +use entity_access::domain::models::{ + AccessLevel, EditAccessLevel, EntityType, OwnerAccessLevel, ViewAccessLevel, +}; +use entity_access::domain::ports::EntityAccessService; +use entity_access::domain::service::EntityAccessServiceImpl; +use entity_access::outbound::PgAccessRepository; +use macro_db_migrator::MACRO_DB_MIGRATIONS; +use macro_event_broker::NoopMacroEventBroker; +use macro_user_id::{cowlike::CowLike, user_id::MacroUserIdStr}; +use models_databases::{CellValue, CellWrite, DatabaseOp, OpResult, RowsChange, RowsResult}; +use models_permissions::share_permission::UpdateSharePermissionRequestV2; +use models_permissions::share_permission::access_level::AccessLevel as ShareAccessLevel; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::domain::models::{CreateDatabase, DatabaseError, Viewer}; +use crate::domain::ports::{DatabasesRepo, DatabasesService}; +use crate::domain::receipt::database_receipt; +use crate::domain::sharing::DatabaseSharingService; +use crate::outbound::gateway_event_publisher::NoOpTableEventPublisher; +use crate::outbound::pg_databases_repo::PgDatabasesRepo; +use crate::wiring::build_service; +use properties::outbound::properties_pg_repo::PropertiesPgRepo; + +async fn insert_user(pool: &PgPool, user_id: &str) -> Viewer { + let macro_user_id = macro_uuid::generate_uuid_v7(); + sqlx::query!( + r#"INSERT INTO macro_user (id, username, email, stripe_customer_id) VALUES ($1, $2, $2, $2)"#, + macro_user_id, + user_id, + ) + .execute(pool) + .await + .unwrap(); + sqlx::query!( + r#"INSERT INTO "User" (id, email, macro_user_id) VALUES ($1, $1, $2)"#, + user_id, + macro_user_id, + ) + .execute(pool) + .await + .unwrap(); + Viewer { + user_id: MacroUserIdStr::parse_from_str(user_id) + .unwrap() + .into_owned(), + acting_bot: None, + } +} + +fn team_share(level: Option) -> UpdateSharePermissionRequestV2 { + UpdateSharePermissionRequestV2 { + link_share: None, + link_share_access_level: None, + team_share_access_level: Some(level), + channel_share_permissions: None, + } +} + +#[sqlx::test(migrator = "MACRO_DB_MIGRATIONS")] +async fn a_teammate_reads_and_writes_a_team_shared_database_until_it_is_unshared(pool: PgPool) { + let run = Uuid::now_v7(); + let owner = insert_user(&pool, &format!("macro|team-share-owner-{run}@macro.com")).await; + let teammate = insert_user(&pool, &format!("macro|team-share-mate-{run}@macro.com")).await; + let outsider = insert_user(&pool, &format!("macro|team-share-out-{run}@macro.com")).await; + let team = Uuid::now_v7(); + sqlx::query!( + "INSERT INTO team (id, name, owner_id, seat_count) VALUES ($1, 'Team', $2, 2)", + team, + owner.user_id.as_ref(), + ) + .execute(&pool) + .await + .unwrap(); + sqlx::query!( + "INSERT INTO team_user (team_id, user_id, team_role) VALUES ($1, $2, 'owner'), ($1, $3, 'member')", + team, + owner.user_id.as_ref(), + teammate.user_id.as_ref(), + ) + .execute(&pool) + .await + .unwrap(); + let access = Arc::new(EntityAccessServiceImpl::new(PgAccessRepository::new( + pool.clone(), + ))); + let service = build_service( + pool.clone(), + access.clone(), + NoOpTableEventPublisher, + NoopMacroEventBroker, + ); + let database = service + .create_database(CreateDatabase { + name: "Roadmap".to_string(), + owner_id: owner.user_id.clone(), + acting_bot: None, + }) + .await + .unwrap(); + let owns = || database_receipt::(access.as_ref(), &owner, database.id); + + assert!( + database_receipt::(access.as_ref(), &teammate, database.id) + .await + .is_err() + ); + let shared = service + .update_share_permissions( + owns().await.unwrap(), + team_share(Some(ShareAccessLevel::Edit)), + ) + .await + .unwrap(); + assert_eq!(shared.team_share_access_level, Some(ShareAccessLevel::Edit)); + assert_eq!( + service + .share_permissions(owns().await.unwrap()) + .await + .unwrap() + .team_share_access_level, + Some(ShareAccessLevel::Edit) + ); + + let listed = service.list_databases(teammate.clone()).await.unwrap(); + assert_eq!( + listed + .iter() + .map(|listed| (listed.database.id, listed.grant)) + .collect::>(), + vec![(database.id, AccessLevel::Edit)] + ); + assert!( + service + .list_databases(outsider.clone()) + .await + .unwrap() + .is_empty() + ); + let detail = service + .get_database( + database_receipt::(access.as_ref(), &teammate, database.id) + .await + .unwrap(), + ) + .await + .unwrap(); + let table = &detail.tables[0]; + let inserted = service + .apply_ops( + database_receipt::(access.as_ref(), &teammate, database.id) + .await + .unwrap(), + teammate.clone(), + vec![DatabaseOp::Rows { + table: table.table.id, + change: RowsChange::Insert { + rows: vec![vec![CellWrite { + column: table.columns[0].column.id, + value: CellValue::Text("Launch".into()), + }]], + }, + }] + .into(), + ) + .await + .unwrap(); + let [ + OpResult::Rows { + change: RowsResult::Inserted { rows }, + .. + }, + ] = inserted.as_slice() + else { + panic!("expected one insert, got {inserted:?}"); + }; + let row = rows[0].to_string(); + assert_eq!( + access + .get_access_level(Some(&teammate.user_id), &row, EntityType::DatabaseRow) + .await + .unwrap(), + Some(AccessLevel::Edit) + ); + assert_eq!( + access + .get_access_level(Some(&outsider.user_id), &row, EntityType::DatabaseRow) + .await + .unwrap(), + None + ); + + // A team share grants no ownership, so the teammate cannot reshare it. + assert!( + database_receipt::(access.as_ref(), &teammate, database.id) + .await + .is_err() + ); + + let unshared = service + .update_share_permissions(owns().await.unwrap(), team_share(None)) + .await + .unwrap(); + assert_eq!(unshared.team_share_access_level, None); + assert!( + service + .list_databases(teammate.clone()) + .await + .unwrap() + .is_empty() + ); + assert_eq!( + access + .get_access_level( + Some(&teammate.user_id), + &database.id.to_string(), + EntityType::Database + ) + .await + .unwrap(), + None + ); + assert_eq!( + access + .get_access_level(Some(&teammate.user_id), &row, EntityType::DatabaseRow) + .await + .unwrap(), + None + ); + assert!(matches!( + service + .update_share_permissions( + owns().await.unwrap(), + team_share(Some(ShareAccessLevel::Owner)) + ) + .await, + Err(DatabaseError::InvalidSharing(_)) + )); + // Deleting the database deletes its sharing settings. + let permission_id = sqlx::query_scalar!( + "SELECT share_permission_id FROM database_entities WHERE database_id = $1", + database.id.into_uuid(), + ) + .fetch_one(&pool) + .await + .unwrap() + .expect("sharing made the database's permission"); + PgDatabasesRepo::new(pool.clone(), PropertiesPgRepo::new(pool.clone())) + .delete_database(database.id) + .await + .unwrap(); + assert_eq!( + sqlx::query_scalar!( + r#"SELECT COUNT(*) AS "count!" FROM "SharePermission" WHERE id = $1"#, + permission_id, + ) + .fetch_one(&pool) + .await + .unwrap(), + 0 + ); +} diff --git a/crates/entity_access/src/outbound/pg_access_repo/queries/database_access.rs b/crates/entity_access/src/outbound/pg_access_repo/queries/database_access.rs index 49df1929638..83ae6918ec9 100644 --- a/crates/entity_access/src/outbound/pg_access_repo/queries/database_access.rs +++ b/crates/entity_access/src/outbound/pg_access_repo/queries/database_access.rs @@ -17,8 +17,9 @@ mod test; /// The highest access level `source_ids` hold on a database: its own grants, /// and Edit through edit or owner on any live form over it, so form editors -/// can read and change their responses. View on a form grants nothing here. A -/// database has no `SharePermission`, so there is no public-sharing arm. +/// can read and change their responses. View on a form grants nothing here. +/// Databases have no share link, so there is no public-sharing arm; a team +/// share is a team-source grant like any other. /// Both grant paths require an app entity; core storage alone is not shareable. #[tracing::instrument(err, skip(pool, source_ids))] pub async fn get_database_access( From 6179f217f8a48f59692a70129f1f8e606fe374c8 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 22 Oct 2026 22:10:28 +0000 Subject: [PATCH 3/7] build(databases): record the share_permission_db_utils dependency closure Why: CI's dependency check fails when the generated closure map lags Cargo.toml. --- .github/workspace-dep-closures.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workspace-dep-closures.json b/.github/workspace-dep-closures.json index d9cf70fd5af..5735243917c 100644 --- a/.github/workspace-dep-closures.json +++ b/.github/workspace-dep-closures.json @@ -4617,6 +4617,7 @@ "crates/properties", "crates/rate_limit", "crates/remote_env_var", + "crates/share_permission_db_utils", "crates/system_properties", "crates/workspace-hack" ], @@ -4681,6 +4682,7 @@ "crates/properties", "crates/rate_limit", "crates/remote_env_var", + "crates/share_permission_db_utils", "crates/system_properties", "crates/workspace-hack" ], From c76a4ac57c97e4a86e0c148682f2c891685073b6 Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 22 Oct 2026 22:10:29 +0000 Subject: [PATCH 4/7] feat(web): offer Team access in the database share dialog Why: the backend now stores a database's team share, but the share dialog only offered people and channels for databases. What: - Treat databases as team-shareable and send the team access level through the database permissions endpoint. - Regenerate the storage OpenAPI, web client, SDK and SDK docs for the team share and the new 409 response. - Describe Team access in the databases agent guide. --- .../component/TopBar/ShareButton.test.tsx | 56 +++++++++++++++++++ .../lib/core/component/TopBar/ShareButton.tsx | 5 ++ .../lib/core/component/TopBar/linkShare.ts | 1 + .../service-storage/generated/zod.ts | 10 ++-- .../service-storage/openapi.json | 14 ++++- docs/agent-guide/databases.md | 5 +- .../UpdateDatabasePermissionsErrors.md | 3 +- packages/sdk/generated/storage/sdk.gen.ts | 10 ++-- packages/sdk/generated/storage/types.gen.ts | 1 + packages/sdk/specs/storage.json | 14 ++++- 10 files changed, 104 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/core/component/TopBar/ShareButton.test.tsx b/apps/web/src/lib/core/component/TopBar/ShareButton.test.tsx index 56b36fb8b5c..7404084fd96 100644 --- a/apps/web/src/lib/core/component/TopBar/ShareButton.test.tsx +++ b/apps/web/src/lib/core/component/TopBar/ShareButton.test.tsx @@ -409,6 +409,7 @@ beforeEach(() => { mocks.updateChatPermissions.mockResolvedValue({ isErr: () => false }); mocks.updateCallTeamShare.mockResolvedValue({ isErr: () => false }); mocks.editProject.mockResolvedValue({ isErr: () => false }); + mocks.updateDatabasePermissions.mockResolvedValue({ isErr: () => false }); mocks.editDocument.mockResolvedValue({ isErr: () => false }); Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -1542,6 +1543,61 @@ it('database sharing reads its block permissions and disables public links', () expect(screen.queryByText('Anyone with the link')).toBeNull(); }); +describe('database team sharing', () => { + function mountDatabaseShare() { + mocks.blockPermissionsRead.mockReturnValue({ + isErr: () => false, + value: { + id: 'database-id', + owner: ME, + teamShareAccessLevel: null, + channelSharePermissions: [], + }, + }); + render(() => ( + + )); + } + + it('lets the owner share the database with their team through its permissions endpoint', async () => { + mocks.hasTeam = true; + mountDatabaseShare(); + + expect( + screen.getByText("Share this database directly with the owner's team.") + ).toBeTruthy(); + fireEvent.click( + screen.getByRole('button', { name: 'Set Team access level edit' }) + ); + + await vi.waitFor(() => + expect(mocks.updateDatabasePermissions).toHaveBeenCalledWith({ + id: 'database-id', + teamShareAccessLevel: 'edit', + }) + ); + expect(mocks.editDocument).not.toHaveBeenCalled(); + }); + + it('hides database team access when the owner has no team', () => { + mocks.hasTeam = false; + mountDatabaseShare(); + + expect( + screen.queryByRole('group', { name: 'Team access level' }) + ).toBeNull(); + }); +}); + describe('form share roles', () => { it.each([false, true])( 'shows form link sharing in the native link area (mobile: %s)', diff --git a/apps/web/src/lib/core/component/TopBar/ShareButton.tsx b/apps/web/src/lib/core/component/TopBar/ShareButton.tsx index 6c1f021a727..d3ebaec6ebe 100644 --- a/apps/web/src/lib/core/component/TopBar/ShareButton.tsx +++ b/apps/web/src/lib/core/component/TopBar/ShareButton.tsx @@ -1358,6 +1358,11 @@ export function ShareModal(props: ShareModalProps) { id: props.id, sharePermission, }); + } else if (props.itemType === 'database') { + result = await updateDatabaseSharePermissions({ + id: props.id, + ...sharePermission, + }); } else { result = await storageServiceClient.editDocument({ sharePermission, diff --git a/apps/web/src/lib/core/component/TopBar/linkShare.ts b/apps/web/src/lib/core/component/TopBar/linkShare.ts index 5d994b22a49..1d14ae641c9 100644 --- a/apps/web/src/lib/core/component/TopBar/linkShare.ts +++ b/apps/web/src/lib/core/component/TopBar/linkShare.ts @@ -16,6 +16,7 @@ const TEAM_SHAREABLE_ITEM_TYPES: ReadonlySet = 'agent_session', 'initiative', 'crm_pipeline', + 'database', ]); export function isTeamShareSupportedForItem(itemType: ShareItemType): boolean { diff --git a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts index 2b79e79b0f9..afbc63b2579 100644 --- a/apps/web/src/lib/service-clients/service-storage/generated/zod.ts +++ b/apps/web/src/lib/service-clients/service-storage/generated/zod.ts @@ -14163,8 +14163,8 @@ export const applyDatabaseOpsResponse = zod .describe('What each op of a batch did.'); /** - * @summary Read recipients for a database owned by the caller. Link and team -sharing are always `null`: databases do not support them yet. + * @summary Read recipients and the team share for a database owned by the caller. +Link sharing is always `null`: databases do not support it. */ export const getDatabasePermissionsParams = zod.object({ id: zod.uuid(), @@ -14213,8 +14213,10 @@ export const getDatabasePermissionsResponse = zod.object({ }); /** - * @summary Update channel recipients after proving database ownership. Turning on -link or team sharing is refused; ownership cannot be changed here. + * @summary Update channel recipients and the team share after proving database +ownership. Turning on link sharing is refused; ownership cannot be +changed here. Only the actual owner may change the team share, and a 409 +means sharing changed since the request was authorized. */ export const updateDatabasePermissionsParams = zod.object({ id: zod.uuid(), diff --git a/apps/web/src/lib/service-clients/service-storage/openapi.json b/apps/web/src/lib/service-clients/service-storage/openapi.json index 1de16dd7112..dc2f601c943 100644 --- a/apps/web/src/lib/service-clients/service-storage/openapi.json +++ b/apps/web/src/lib/service-clients/service-storage/openapi.json @@ -7753,7 +7753,7 @@ "/databases/{id}/permissions": { "get": { "tags": ["databases"], - "summary": "Read recipients for a database owned by the caller. Link and team\nsharing are always `null`: databases do not support them yet.", + "summary": "Read recipients and the team share for a database owned by the caller.\nLink sharing is always `null`: databases do not support it.", "operationId": "get_database_permissions", "parameters": [ { @@ -7821,7 +7821,7 @@ }, "patch": { "tags": ["databases"], - "summary": "Update channel recipients after proving database ownership. Turning on\nlink or team sharing is refused; ownership cannot be changed here.", + "summary": "Update channel recipients and the team share after proving database\nownership. Turning on link sharing is refused; ownership cannot be\nchanged here. Only the actual owner may change the team share, and a 409\nmeans sharing changed since the request was authorized.", "operationId": "update_database_permissions", "parameters": [ { @@ -7895,6 +7895,16 @@ } } }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "", "content": { diff --git a/docs/agent-guide/databases.md b/docs/agent-guide/databases.md index 9d275ceddc7..14d1490d5ea 100644 --- a/docs/agent-guide/databases.md +++ b/docs/agent-guide/databases.md @@ -616,8 +616,9 @@ details**, and the server's summary on query tool rows. ## Sharing and files **Share** opens Macro's standard sharing dialog. The owner can share with people -or channels and change or remove their access. Databases do not offer a public -link. **Share** and viewer avatars (other people currently looking at the +or channels and change or remove their access, and an owner on a team sets +**Team access** (view, comment, or edit for the owner's team; rows follow the +database). Databases do not offer a public link. **Share** and viewer avatars (other people currently looking at the database) sit at the right of the split header, like other entities. The toolbar's **Database actions** (`…`) menu, beside **AI**, contains **Rename**, diff --git a/packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/UpdateDatabasePermissionsErrors.md b/packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/UpdateDatabasePermissionsErrors.md index 8280880d119..8b61b796198 100644 --- a/packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/UpdateDatabasePermissionsErrors.md +++ b/packages/sdk-docs/reference/generated/storage/types.gen/type-aliases/UpdateDatabasePermissionsErrors.md @@ -1,6 +1,6 @@ # Type Alias: UpdateDatabasePermissionsErrors -> **UpdateDatabasePermissionsErrors** = \{ `400`: [`ErrorResponse`](ErrorResponse.md); `401`: [`ErrorResponse`](ErrorResponse.md); `403`: [`ErrorResponse`](ErrorResponse.md); `404`: [`ErrorResponse`](ErrorResponse.md); `500`: [`ErrorResponse`](ErrorResponse.md); \} +> **UpdateDatabasePermissionsErrors** = \{ `400`: [`ErrorResponse`](ErrorResponse.md); `401`: [`ErrorResponse`](ErrorResponse.md); `403`: [`ErrorResponse`](ErrorResponse.md); `404`: [`ErrorResponse`](ErrorResponse.md); `409`: [`ErrorResponse`](ErrorResponse.md); `500`: [`ErrorResponse`](ErrorResponse.md); \} ## Properties @@ -10,4 +10,5 @@ | `401` | [`ErrorResponse`](ErrorResponse.md) | | `403` | [`ErrorResponse`](ErrorResponse.md) | | `404` | [`ErrorResponse`](ErrorResponse.md) | +| `409` | [`ErrorResponse`](ErrorResponse.md) | | `500` | [`ErrorResponse`](ErrorResponse.md) | diff --git a/packages/sdk/generated/storage/sdk.gen.ts b/packages/sdk/generated/storage/sdk.gen.ts index fd085ffa733..76fcd077808 100644 --- a/packages/sdk/generated/storage/sdk.gen.ts +++ b/packages/sdk/generated/storage/sdk.gen.ts @@ -1461,16 +1461,18 @@ export class Sdk extends HeyApiClient { } /** - * Read recipients for a database owned by the caller. Link and team - * sharing are always `null`: databases do not support them yet. + * Read recipients and the team share for a database owned by the caller. + * Link sharing is always `null`: databases do not support it. */ public getDatabasePermissions(options: Options): RequestResult { return (options.client ?? this.client).get({ url: '/databases/{id}/permissions', ...options }); } /** - * Update channel recipients after proving database ownership. Turning on - * link or team sharing is refused; ownership cannot be changed here. + * Update channel recipients and the team share after proving database + * ownership. Turning on link sharing is refused; ownership cannot be + * changed here. Only the actual owner may change the team share, and a 409 + * means sharing changed since the request was authorized. */ public updateDatabasePermissions(options: Options): RequestResult { return (options.client ?? this.client).patch({ diff --git a/packages/sdk/generated/storage/types.gen.ts b/packages/sdk/generated/storage/types.gen.ts index 0399f0998df..edc99a79bae 100644 --- a/packages/sdk/generated/storage/types.gen.ts +++ b/packages/sdk/generated/storage/types.gen.ts @@ -18638,6 +18638,7 @@ export type UpdateDatabasePermissionsErrors = { 401: ErrorResponse; 403: ErrorResponse; 404: ErrorResponse; + 409: ErrorResponse; 500: ErrorResponse; }; diff --git a/packages/sdk/specs/storage.json b/packages/sdk/specs/storage.json index 1de16dd7112..dc2f601c943 100644 --- a/packages/sdk/specs/storage.json +++ b/packages/sdk/specs/storage.json @@ -7753,7 +7753,7 @@ "/databases/{id}/permissions": { "get": { "tags": ["databases"], - "summary": "Read recipients for a database owned by the caller. Link and team\nsharing are always `null`: databases do not support them yet.", + "summary": "Read recipients and the team share for a database owned by the caller.\nLink sharing is always `null`: databases do not support it.", "operationId": "get_database_permissions", "parameters": [ { @@ -7821,7 +7821,7 @@ }, "patch": { "tags": ["databases"], - "summary": "Update channel recipients after proving database ownership. Turning on\nlink or team sharing is refused; ownership cannot be changed here.", + "summary": "Update channel recipients and the team share after proving database\nownership. Turning on link sharing is refused; ownership cannot be\nchanged here. Only the actual owner may change the team share, and a 409\nmeans sharing changed since the request was authorized.", "operationId": "update_database_permissions", "parameters": [ { @@ -7895,6 +7895,16 @@ } } }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, "500": { "description": "", "content": { From edb18f9d9eb7d46bef597b015a1e4d64e6c2754f Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 22 Oct 2026 22:57:27 +0000 Subject: [PATCH 5/7] docs(share-permissions): list databases among the team share writer's users Why: databases now call team_share, and the crate guide's Used by list left them out. --- crates/share_permission_db_utils/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/share_permission_db_utils/AGENTS.md b/crates/share_permission_db_utils/AGENTS.md index 231fc2b0a40..63d69db63d2 100644 --- a/crates/share_permission_db_utils/AGENTS.md +++ b/crates/share_permission_db_utils/AGENTS.md @@ -26,7 +26,7 @@ team-level link-share defaults and team sharing. - Depends on: `entity_access_db_utils`, `models_permissions`, `macro_user_id`, `macro_uuid`, `model_owner`. -- Used by: `agent_session`, `call`, `channels`, `chat`, `documents`, +- Used by: `agent_session`, `call`, `channels`, `chat`, `databases`, `documents`, `initiative`, `projects`. ## Recipes From eb58f601adf57a7ba09e527efa25f0217b32130d Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 22 Oct 2026 23:19:52 +0000 Subject: [PATCH 6/7] test(channels): name the unnamed channel test's bot with a bot that still exists Why: #8474 removed CLAUDE_BOT_ID while #8364 added a channel name test that uses it, so the channels lib tests stopped compiling on main. --- crates/channels/src/outbound/channel_name/test.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/channels/src/outbound/channel_name/test.rs b/crates/channels/src/outbound/channel_name/test.rs index 4dd88adaa0a..611521acb52 100644 --- a/crates/channels/src/outbound/channel_name/test.rs +++ b/crates/channels/src/outbound/channel_name/test.rs @@ -25,14 +25,14 @@ fn unnamed_private_channel_lists_people_and_bots_but_not_the_viewer() { let participants = vec![ "macro|me@test.com".to_string(), "macro|gabriel@test.com".to_string(), - "bot|00000000-0000-0000-0000-00000000c1a0".to_string(), + "bot|00000000-0000-0000-0000-00000000c0de".to_string(), ]; let mut user_names = NameLookup::new(); user_names.insert( MacroUserIdStr::try_from("macro|gabriel@test.com".to_string()).unwrap(), "Gabriel Birman".to_string(), ); - let bot_names = HashMap::from([(bot_id::CLAUDE_BOT_ID, "Claude".to_string())]); + let bot_names = HashMap::from([(bot_id::CODEX_BOT_ID, "Codex".to_string())]); let name = resolve_channel_name( ChannelType::Private, @@ -44,5 +44,5 @@ fn unnamed_private_channel_lists_people_and_bots_but_not_the_viewer() { &bot_names, ); - assert_eq!(name, "Claude, Gabriel Birman"); + assert_eq!(name, "Codex, Gabriel Birman"); } From cbbaebe4fd67cb977ef911c72d3060e61f11de1f Mon Sep 17 00:00:00 2001 From: Wolf Mermelstein Date: Thu, 22 Oct 2026 23:49:06 +0000 Subject: [PATCH 7/7] test(name-search): find persona DMs by a system bot that still exists Why: #8474 dropped the @claude system bot, so the DM fixture's bot now resolves as "Bot" and the name search test no longer finds "Claude" on main. --- crates/name_search/fixtures/channel.sql | 2 +- crates/name_search/src/channel/test.rs | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/name_search/fixtures/channel.sql b/crates/name_search/fixtures/channel.sql index 03b7f9aa037..a053ac3daca 100644 --- a/crates/name_search/fixtures/channel.sql +++ b/crates/name_search/fixtures/channel.sql @@ -58,7 +58,7 @@ VALUES ('88888888-8888-8888-8888-888888888888', 'owner', 'macro|member@test.com', NULL), ('88888888-8888-8888-8888-888888888888', 'member', 'macro|nameless@test.com', NULL), ('99999999-9999-9999-9999-999999999999', 'owner', 'macro|member@test.com', NULL), - ('99999999-9999-9999-9999-999999999999', 'member', 'bot|00000000-0000-0000-0000-00000000c1a0', NULL), + ('99999999-9999-9999-9999-999999999999', 'member', 'bot|00000000-0000-0000-0000-00000000c0de', NULL), ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'owner', 'macro|member@test.com', NULL), ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'member', 'bot|b0000000-0000-0000-0000-000000000001', NULL), ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'owner', 'macro|member@test.com', NULL), diff --git a/crates/name_search/src/channel/test.rs b/crates/name_search/src/channel/test.rs index 158b7627822..c9f74c98abe 100644 --- a/crates/name_search/src/channel/test.rs +++ b/crates/name_search/src/channel/test.rs @@ -13,7 +13,7 @@ const MACARONI: &str = "33333333-3333-3333-3333-333333333333"; const GABRIEL_DM: &str = "66666666-6666-6666-6666-666666666666"; const PETER_TEO_GROUP: &str = "77777777-7777-7777-7777-777777777777"; const NAMELESS_DM: &str = "88888888-8888-8888-8888-888888888888"; -const CLAUDE_DM: &str = "99999999-9999-9999-9999-999999999999"; +const CODEX_DM: &str = "99999999-9999-9999-9999-999999999999"; const DEAL_DESK_DM: &str = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; const DESIGN_DECKS: &str = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; const HOLLIS_DM: &str = "cccccccc-cccc-cccc-cccc-cccccccccccc"; @@ -395,14 +395,14 @@ async fn persona_direct_messages_are_found_by_bot_name(pool: Pool) -> &pool, &user(MEMBER), ChannelNameSearchScope::default(), - "claude".to_string(), + "codex".to_string(), false, 10, None, ) .await?; - assert_eq!(ids(&system_bot), [CLAUDE_DM]); - assert_eq!(system_bot.items[0].name, "Claude"); + assert_eq!(ids(&system_bot), [CODEX_DM]); + assert_eq!(system_bot.items[0].name, "Codex"); let owned_bot = search_channel_names( &pool, @@ -605,7 +605,7 @@ async fn results_match_the_names_the_viewer_sees(pool: Pool) -> anyhow "teo", "nys", "nameless", - "claude", + "codex", "deal", "des", "an",