max / makenotwork
8 files changed,
+253 insertions,
-27 deletions
| @@ -22,6 +22,7 @@ use crate::error::Result; | |||
| 22 | 22 | #[tracing::instrument(skip_all)] | |
| 23 | 23 | pub async fn create_group( | |
| 24 | 24 | pool: &PgPool, | |
| 25 | + | id: SyncGroupId, | |
| 25 | 26 | app_id: SyncAppId, | |
| 26 | 27 | admin_user_id: UserId, | |
| 27 | 28 | name: &str, | |
| @@ -30,13 +31,16 @@ pub async fn create_group( | |||
| 30 | 31 | ) -> Result<DbSyncGroup> { | |
| 31 | 32 | let mut tx = pool.begin().await?; | |
| 32 | 33 | ||
| 34 | + | // The id is client-generated (the admin's GCK grant is sealed bound to it | |
| 35 | + | // before the group exists). A PK collision surfaces as a DB error. | |
| 33 | 36 | let group = sqlx::query_as::<_, DbSyncGroup>( | |
| 34 | 37 | r#" | |
| 35 | - | INSERT INTO sync_groups (app_id, admin_user_id, name) | |
| 36 | - | VALUES ($1, $2, $3) | |
| 38 | + | INSERT INTO sync_groups (id, app_id, admin_user_id, name) | |
| 39 | + | VALUES ($1, $2, $3, $4) | |
| 37 | 40 | RETURNING id, app_id, admin_user_id, name, gck_version, created_at | |
| 38 | 41 | "#, | |
| 39 | 42 | ) | |
| 43 | + | .bind(id) | |
| 40 | 44 | .bind(app_id) | |
| 41 | 45 | .bind(admin_user_id) | |
| 42 | 46 | .bind(name) |
| @@ -89,6 +89,7 @@ pub(super) async fn create_group( | |||
| 89 | 89 | ||
| 90 | 90 | let group = db::synckit::create_group( | |
| 91 | 91 | &db, | |
| 92 | + | req.id, | |
| 92 | 93 | sync_user.app_id, | |
| 93 | 94 | sync_user.user_id, | |
| 94 | 95 | &req.name, |
| @@ -196,6 +196,12 @@ pub(crate) struct SyncAccountResponse { | |||
| 196 | 196 | ||
| 197 | 197 | #[derive(Deserialize, utoipa::ToSchema)] | |
| 198 | 198 | pub(crate) struct CreateGroupRequest { | |
| 199 | + | /// The group id, generated client-side. The admin seals the GCK grant bound | |
| 200 | + | /// to this id before the group exists (the grant's AAD binds the group id), so | |
| 201 | + | /// the id must be chosen by the client, not the server. A UUID collision (PK | |
| 202 | + | /// conflict) is rejected. | |
| 203 | + | #[schema(value_type = String)] | |
| 204 | + | pub id: SyncGroupId, | |
| 199 | 205 | pub name: String, | |
| 200 | 206 | /// The Group Content Key sealed to the creating admin's own identity public | |
| 201 | 207 | /// key (base64), opaque to the server. |
| @@ -9,7 +9,7 @@ | |||
| 9 | 9 | ||
| 10 | 10 | use crate::harness::db::TestDb; | |
| 11 | 11 | use makenotwork::db::synckit; | |
| 12 | - | use makenotwork::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 12 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, SyncGroupId, UserId}; | |
| 13 | 13 | use uuid::Uuid; | |
| 14 | 14 | ||
| 15 | 15 | /// Seed a verified user. | |
| @@ -90,9 +90,17 @@ async fn create_group_enrolls_admin_as_member_and_admin() { | |||
| 90 | 90 | let admin = seed_user(&db.pool, "grp_admin").await; | |
| 91 | 91 | let app = seed_app(&db.pool, admin, "grpcreate").await; | |
| 92 | 92 | ||
| 93 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk") | |
| 94 | - | .await | |
| 95 | - | .unwrap(); | |
| 93 | + | let group = synckit::create_group( | |
| 94 | + | &db.pool, | |
| 95 | + | SyncGroupId::new(), | |
| 96 | + | app, | |
| 97 | + | admin, | |
| 98 | + | "Team", | |
| 99 | + | "sealed_admin", | |
| 100 | + | "pk", | |
| 101 | + | ) | |
| 102 | + | .await | |
| 103 | + | .unwrap(); | |
| 96 | 104 | ||
| 97 | 105 | assert_eq!(group.gck_version, 1); | |
| 98 | 106 | assert!( | |
| @@ -118,9 +126,17 @@ async fn add_member_stores_grant_and_gates_admin() { | |||
| 118 | 126 | let db = TestDb::new().await; | |
| 119 | 127 | let admin = seed_user(&db.pool, "gm_admin").await; | |
| 120 | 128 | let app = seed_app(&db.pool, admin, "gmadd").await; | |
| 121 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk") | |
| 122 | - | .await | |
| 123 | - | .unwrap(); | |
| 129 | + | let group = synckit::create_group( | |
| 130 | + | &db.pool, | |
| 131 | + | SyncGroupId::new(), | |
| 132 | + | app, | |
| 133 | + | admin, | |
| 134 | + | "Team", | |
| 135 | + | "sealed_admin", | |
| 136 | + | "pk", | |
| 137 | + | ) | |
| 138 | + | .await | |
| 139 | + | .unwrap(); | |
| 124 | 140 | ||
| 125 | 141 | let bob = seed_user(&db.pool, "gm_bob").await; | |
| 126 | 142 | synckit::add_or_update_member( | |
| @@ -172,9 +188,17 @@ async fn add_or_update_member_is_idempotent_upsert() { | |||
| 172 | 188 | let db = TestDb::new().await; | |
| 173 | 189 | let admin = seed_user(&db.pool, "up_admin").await; | |
| 174 | 190 | let app = seed_app(&db.pool, admin, "upsert").await; | |
| 175 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk") | |
| 176 | - | .await | |
| 177 | - | .unwrap(); | |
| 191 | + | let group = synckit::create_group( | |
| 192 | + | &db.pool, | |
| 193 | + | SyncGroupId::new(), | |
| 194 | + | app, | |
| 195 | + | admin, | |
| 196 | + | "Team", | |
| 197 | + | "sealed_admin", | |
| 198 | + | "pk", | |
| 199 | + | ) | |
| 200 | + | .await | |
| 201 | + | .unwrap(); | |
| 178 | 202 | let bob = seed_user(&db.pool, "up_bob").await; | |
| 179 | 203 | ||
| 180 | 204 | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1, "pk") | |
| @@ -206,10 +230,10 @@ async fn list_groups_for_user_is_membership_scoped() { | |||
| 206 | 230 | let db = TestDb::new().await; | |
| 207 | 231 | let admin = seed_user(&db.pool, "lg_admin").await; | |
| 208 | 232 | let app = seed_app(&db.pool, admin, "listgrp").await; | |
| 209 | - | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s", "pk") | |
| 233 | + | let g1 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "One", "s", "pk") | |
| 210 | 234 | .await | |
| 211 | 235 | .unwrap(); | |
| 212 | - | let _g2 = synckit::create_group(&db.pool, app, admin, "Two", "s", "pk") | |
| 236 | + | let _g2 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Two", "s", "pk") | |
| 213 | 237 | .await | |
| 214 | 238 | .unwrap(); | |
| 215 | 239 | ||
| @@ -247,7 +271,7 @@ async fn remove_member_revokes_membership() { | |||
| 247 | 271 | let db = TestDb::new().await; | |
| 248 | 272 | let admin = seed_user(&db.pool, "rm_admin").await; | |
| 249 | 273 | let app = seed_app(&db.pool, admin, "removem").await; | |
| 250 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "s", "pk") | |
| 274 | + | let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk") | |
| 251 | 275 | .await | |
| 252 | 276 | .unwrap(); | |
| 253 | 277 | let bob = seed_user(&db.pool, "rm_bob").await; | |
| @@ -280,7 +304,7 @@ async fn group_push_pull_roundtrip_and_idempotent() { | |||
| 280 | 304 | let db = TestDb::new().await; | |
| 281 | 305 | let admin = seed_user(&db.pool, "pp_admin").await; | |
| 282 | 306 | let app = seed_app(&db.pool, admin, "pushpull").await; | |
| 283 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "s", "pk") | |
| 307 | + | let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Team", "s", "pk") | |
| 284 | 308 | .await | |
| 285 | 309 | .unwrap(); | |
| 286 | 310 | let device = seed_device(&db.pool, app, admin, "dev").await; | |
| @@ -316,10 +340,10 @@ async fn group_pull_is_scoped_to_its_group() { | |||
| 316 | 340 | let db = TestDb::new().await; | |
| 317 | 341 | let admin = seed_user(&db.pool, "sc_admin").await; | |
| 318 | 342 | let app = seed_app(&db.pool, admin, "scoped").await; | |
| 319 | - | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s", "pk") | |
| 343 | + | let g1 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "One", "s", "pk") | |
| 320 | 344 | .await | |
| 321 | 345 | .unwrap(); | |
| 322 | - | let g2 = synckit::create_group(&db.pool, app, admin, "Two", "s", "pk") | |
| 346 | + | let g2 = synckit::create_group(&db.pool, SyncGroupId::new(), app, admin, "Two", "s", "pk") | |
| 323 | 347 | .await | |
| 324 | 348 | .unwrap(); | |
| 325 | 349 | let device = seed_device(&db.pool, app, admin, "dev").await; | |
| @@ -371,7 +395,7 @@ async fn group_entries_never_appear_in_personal_sync() { | |||
| 371 | 395 | let db = TestDb::new().await; | |
| 372 | 396 | let user = seed_user(&db.pool, "iso_user").await; | |
| 373 | 397 | let app = seed_app(&db.pool, user, "isolate").await; | |
| 374 | - | let group = synckit::create_group(&db.pool, app, user, "Team", "s", "pk") | |
| 398 | + | let group = synckit::create_group(&db.pool, SyncGroupId::new(), app, user, "Team", "s", "pk") | |
| 375 | 399 | .await | |
| 376 | 400 | .unwrap(); | |
| 377 | 401 | let device = seed_device(&db.pool, app, user, "dev").await; | |
| @@ -438,9 +462,17 @@ async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() { | |||
| 438 | 462 | let db = TestDb::new().await; | |
| 439 | 463 | let admin = seed_user(&db.pool, "rot_admin").await; | |
| 440 | 464 | let app = seed_app(&db.pool, admin, "grprot").await; | |
| 441 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "admin_v1", "pk") | |
| 442 | - | .await | |
| 443 | - | .unwrap(); | |
| 465 | + | let group = synckit::create_group( | |
| 466 | + | &db.pool, | |
| 467 | + | SyncGroupId::new(), | |
| 468 | + | app, | |
| 469 | + | admin, | |
| 470 | + | "Team", | |
| 471 | + | "admin_v1", | |
| 472 | + | "pk", | |
| 473 | + | ) | |
| 474 | + | .await | |
| 475 | + | .unwrap(); | |
| 444 | 476 | let bob = seed_user(&db.pool, "rot_bob").await; | |
| 445 | 477 | let carol = seed_user(&db.pool, "rot_carol").await; | |
| 446 | 478 | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "bob_v1", 1, "pk") | |
| @@ -485,9 +517,17 @@ async fn list_member_pubkeys_returns_admin_and_members() { | |||
| 485 | 517 | let db = TestDb::new().await; | |
| 486 | 518 | let admin = seed_user(&db.pool, "pk_admin").await; | |
| 487 | 519 | let app = seed_app(&db.pool, admin, "pubkeys").await; | |
| 488 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "admin_pub") | |
| 489 | - | .await | |
| 490 | - | .unwrap(); | |
| 520 | + | let group = synckit::create_group( | |
| 521 | + | &db.pool, | |
| 522 | + | SyncGroupId::new(), | |
| 523 | + | app, | |
| 524 | + | admin, | |
| 525 | + | "Team", | |
| 526 | + | "sealed_admin", | |
| 527 | + | "admin_pub", | |
| 528 | + | ) | |
| 529 | + | .await | |
| 530 | + | .unwrap(); | |
| 491 | 531 | let bob = seed_user(&db.pool, "pk_bob").await; | |
| 492 | 532 | synckit::add_or_update_member( | |
| 493 | 533 | &db.pool, |
| @@ -19,7 +19,7 @@ async fn create_group(h: &mut TestHarness, name: &str) -> String { | |||
| 19 | 19 | .client | |
| 20 | 20 | .post_json( | |
| 21 | 21 | "/api/sync/groups", | |
| 22 | - | &json!({ "name": name, "admin_sealed_gck": "sealed_admin", "admin_pubkey": "pk_admin" }).to_string(), | |
| 22 | + | &json!({ "id": uuid::Uuid::new_v4().to_string(), "name": name, "admin_sealed_gck": "sealed_admin", "admin_pubkey": "pk_admin" }).to_string(), | |
| 23 | 23 | ) | |
| 24 | 24 | .await; | |
| 25 | 25 | assert_eq!(resp.status, 200, "create group: {}", resp.text); |
| @@ -14,13 +14,34 @@ use uuid::Uuid; | |||
| 14 | 14 | use crate::{ | |
| 15 | 15 | crypto, | |
| 16 | 16 | error::Result, | |
| 17 | - | ids::{DeviceId, GroupId}, | |
| 17 | + | identity::{IdentityKeypair, IdentityPublicKey, generate_group_key, seal_gck_to_member}, | |
| 18 | + | ids::{DeviceId, GroupId, UserId}, | |
| 18 | 19 | types::*, | |
| 19 | 20 | }; | |
| 20 | 21 | ||
| 21 | 22 | use super::SyncKitClient; | |
| 22 | 23 | use super::helpers::{Idempotency, check_response}; | |
| 23 | 24 | ||
| 25 | + | /// Request body for `POST /groups`. The id is client-generated so the admin's | |
| 26 | + | /// grant can be sealed bound to it before the group exists. | |
| 27 | + | #[derive(serde::Serialize)] | |
| 28 | + | struct CreateGroupBody<'a> { | |
| 29 | + | id: GroupId, | |
| 30 | + | name: &'a str, | |
| 31 | + | admin_sealed_gck: String, | |
| 32 | + | admin_pubkey: String, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// Request body for `POST /groups/{id}/members`. | |
| 36 | + | #[derive(serde::Serialize)] | |
| 37 | + | struct AddMemberBody<'a> { | |
| 38 | + | member_email: &'a str, | |
| 39 | + | sealed_gck: String, | |
| 40 | + | member_pubkey: &'a str, | |
| 41 | + | #[serde(skip_serializing_if = "Option::is_none")] | |
| 42 | + | role: Option<&'a str>, | |
| 43 | + | } | |
| 44 | + | ||
| 24 | 45 | impl SyncKitClient { | |
| 25 | 46 | /// List the groups the authenticated user belongs to within this app. | |
| 26 | 47 | #[instrument(skip(self))] | |
| @@ -33,6 +54,118 @@ impl SyncKitClient { | |||
| 33 | 54 | .await | |
| 34 | 55 | } | |
| 35 | 56 | ||
| 57 | + | /// This user's identity public key (base64), derived from their master key. | |
| 58 | + | /// A member shares it with a group admin so the admin can seal the GCK to it | |
| 59 | + | /// (the paste-a-public-key model). Requires the master key to be loaded. | |
| 60 | + | pub fn my_identity_public_key(&self) -> Result<String> { | |
| 61 | + | let master = self.require_master_key()?; | |
| 62 | + | Ok(IdentityKeypair::from_master_key(&master) | |
| 63 | + | .public_key() | |
| 64 | + | .to_base64()) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | /// Create a group with the caller as its admin and first member. Mints a | |
| 68 | + | /// fresh Group Content Key, seals it to the caller's own identity public key | |
| 69 | + | /// (derived from the master key), and registers the group. | |
| 70 | + | /// | |
| 71 | + | /// The group id is generated client-side: the admin's grant is sealed with the | |
| 72 | + | /// group id bound as associated data, so the id must be known before the seal, | |
| 73 | + | /// before the server round-trip. Generation starts at 1. | |
| 74 | + | #[instrument(skip(self))] | |
| 75 | + | pub async fn create_group(&self, name: &str) -> Result<SyncGroup> { | |
| 76 | + | let token = self.require_token()?; | |
| 77 | + | let master = self.require_master_key()?; | |
| 78 | + | let identity = IdentityKeypair::from_master_key(&master); | |
| 79 | + | let group_id = GroupId::new(Uuid::new_v4()); | |
| 80 | + | let gck = generate_group_key(); | |
| 81 | + | let sealed = seal_gck_to_member(&gck, &identity.public_key(), &group_id.to_string(), 1)?; | |
| 82 | + | ||
| 83 | + | let body = Bytes::from(serde_json::to_vec(&CreateGroupBody { | |
| 84 | + | id: group_id, | |
| 85 | + | name, | |
| 86 | + | admin_sealed_gck: sealed, | |
| 87 | + | admin_pubkey: identity.public_key().to_base64(), | |
| 88 | + | })?); | |
| 89 | + | let url = self.endpoints.groups().to_string(); | |
| 90 | + | ||
| 91 | + | // The client-chosen id makes this safe to retry: a replay hits the PK and | |
| 92 | + | // the group already exists, but the sealed grant is identical. | |
| 93 | + | self.retry_request_json(Idempotency::Keyed, || { | |
| 94 | + | let req = self | |
| 95 | + | .http | |
| 96 | + | .post(&url) | |
| 97 | + | .bearer_auth(&token) | |
| 98 | + | .header("content-type", "application/json") | |
| 99 | + | .body(body.clone()); | |
| 100 | + | async move { check_response(req.send().await?).await } | |
| 101 | + | }) | |
| 102 | + | .await | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// Add a member to a group by account email, sealing the group's current GCK | |
| 106 | + | /// to the member's identity public key (obtained out of band — the member | |
| 107 | + | /// pastes their public key from [`my_identity_public_key`]). Admin only. | |
| 108 | + | #[instrument(skip(self, member_pubkey_b64))] | |
| 109 | + | pub async fn add_member( | |
| 110 | + | &self, | |
| 111 | + | group_id: GroupId, | |
| 112 | + | member_email: &str, | |
| 113 | + | member_pubkey_b64: &str, | |
| 114 | + | ) -> Result<()> { | |
| 115 | + | let token = self.require_token()?; | |
| 116 | + | // Resolve the group's current GCK (and its generation) from our own grant. | |
| 117 | + | let grant = self.group_grant(group_id).await?; | |
| 118 | + | let master = self.require_master_key()?; | |
| 119 | + | let gck = Self::open_group_grant(&grant, &master, group_id)?; | |
| 120 | + | ||
| 121 | + | let member_pubkey = IdentityPublicKey::from_base64(member_pubkey_b64)?; | |
| 122 | + | let sealed = seal_gck_to_member( | |
| 123 | + | &gck, | |
| 124 | + | &member_pubkey, | |
| 125 | + | &group_id.to_string(), | |
| 126 | + | grant.gck_version, | |
| 127 | + | )?; | |
| 128 | + | ||
| 129 | + | let body = Bytes::from(serde_json::to_vec(&AddMemberBody { | |
| 130 | + | member_email, | |
| 131 | + | sealed_gck: sealed, | |
| 132 | + | member_pubkey: member_pubkey_b64, | |
| 133 | + | role: None, | |
| 134 | + | })?); | |
| 135 | + | let url = self.endpoints.group_members(group_id); | |
| 136 | + | ||
| 137 | + | self.retry_request(Idempotency::Keyed, || { | |
| 138 | + | let req = self | |
| 139 | + | .http | |
| 140 | + | .post(&url) | |
| 141 | + | .bearer_auth(&token) | |
| 142 | + | .header("content-type", "application/json") | |
| 143 | + | .body(body.clone()); | |
| 144 | + | async move { check_response(req.send().await?).await } | |
| 145 | + | }) | |
| 146 | + | .await?; | |
| 147 | + | Ok(()) | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// Remove a member from a group by user id. Admin only. The server membership | |
| 151 | + | /// ACL revokes the member's group read/write access immediately. | |
| 152 | + | /// | |
| 153 | + | /// Forward secrecy for writes made *after* removal requires rotating the GCK | |
| 154 | + | /// (re-mint, re-seal to the remaining members, bump the generation), which the | |
| 155 | + | /// server does not yet expose — a later slice. This method performs the | |
| 156 | + | /// membership revocation only; data the member already pulled is in their hands. | |
| 157 | + | #[instrument(skip(self))] | |
| 158 | + | pub async fn remove_member(&self, group_id: GroupId, member: UserId) -> Result<()> { | |
| 159 | + | let token = self.require_token()?; | |
| 160 | + | let url = self.endpoints.group_member(group_id, member); | |
| 161 | + | self.retry_request(Idempotency::Keyed, || { | |
| 162 | + | let req = self.http.delete(&url).bearer_auth(&token); | |
| 163 | + | async move { check_response(req.send().await?).await } | |
| 164 | + | }) | |
| 165 | + | .await?; | |
| 166 | + | Ok(()) | |
| 167 | + | } | |
| 168 | + | ||
| 36 | 169 | /// Fetch the caller's own sealed GCK grant for a group. Open it with the | |
| 37 | 170 | /// member's identity private key to recover the GCK. | |
| 38 | 171 | #[instrument(skip(self))] | |
| @@ -278,6 +411,31 @@ mod tests { | |||
| 278 | 411 | } | |
| 279 | 412 | ||
| 280 | 413 | #[test] | |
| 414 | + | fn create_group_admin_self_grant_is_openable() { | |
| 415 | + | // Mirrors create_group's sealing path: a client-generated group id, the | |
| 416 | + | // GCK sealed to the admin's own derived identity at generation 1. The | |
| 417 | + | // admin's device must be able to open its own grant back to the same GCK, | |
| 418 | + | // or it would be locked out of the group it just created. | |
| 419 | + | let master = crypto::generate_master_key(); | |
| 420 | + | let identity = crate::identity::IdentityKeypair::from_master_key(&master); | |
| 421 | + | let group = GroupId::new(uuid::Uuid::new_v4()); | |
| 422 | + | let gck = crate::identity::generate_group_key(); | |
| 423 | + | let sealed = crate::identity::seal_gck_to_member( | |
| 424 | + | &gck, | |
| 425 | + | &identity.public_key(), | |
| 426 | + | &group.to_string(), | |
| 427 | + | 1, | |
| 428 | + | ) | |
| 429 | + | .unwrap(); | |
| 430 | + | let grant = GroupGrant { | |
| 431 | + | sealed_gck: sealed, | |
| 432 | + | gck_version: 1, | |
| 433 | + | }; | |
| 434 | + | let opened = SyncKitClient::open_group_grant(&grant, &master, group).unwrap(); | |
| 435 | + | assert_eq!(*opened, gck); | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | #[test] | |
| 281 | 439 | fn open_group_grant_recovers_gck_via_derived_identity() { | |
| 282 | 440 | // The admin seals the GCK to the member's derived public key; the member's | |
| 283 | 441 | // client re-derives the identity from its master key and opens the grant. |
| @@ -178,6 +178,16 @@ impl Endpoints { | |||
| 178 | 178 | format!("{}/{group_id}/grant", self.groups_base) | |
| 179 | 179 | } | |
| 180 | 180 | ||
| 181 | + | /// `POST` here to add a member to a group (admin only). | |
| 182 | + | fn group_members(&self, group_id: GroupId) -> String { | |
| 183 | + | format!("{}/{group_id}/members", self.groups_base) | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// `DELETE` here to remove a member from a group (admin only). | |
| 187 | + | fn group_member(&self, group_id: GroupId, member: UserId) -> String { | |
| 188 | + | format!("{}/{group_id}/members/{member}", self.groups_base) | |
| 189 | + | } | |
| 190 | + | ||
| 181 | 191 | /// `POST` encrypted changes to a group's shared changelog. | |
| 182 | 192 | fn group_push(&self, group_id: GroupId) -> String { | |
| 183 | 193 | format!("{}/{group_id}/push", self.groups_base) |
| @@ -92,6 +92,13 @@ impl SyncTable { | |||
| 92 | 92 | self.name | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | + | /// The local provenance column that routes this table's rows to a group | |
| 96 | + | /// scope, or `None` if the table is personal-only. Public so a consumer can | |
| 97 | + | /// assert exactly which tables are group-scoped (GoingsOn's M3 check). | |
| 98 | + | pub fn group_scope(&self) -> Option<&'static str> { | |
| 99 | + | self.group_scope | |
| 100 | + | } | |
| 101 | + | ||
| 95 | 102 | /// A table with the common defaults: single `id` primary key, cleartext | |
| 96 | 103 | /// wire row-id, `Full` upsert, `Hard` delete, no preserved columns, no insert | |
| 97 | 104 | /// defaults, closed referential integrity, no row exclusion. |