Skip to main content

max / makenotwork

Add group admin methods + client-generated group id (Groups p4 M3) Client (synckit-client) gains the group admin surface that wraps the existing server endpoints and crypto: - create_group: mints a GCK, seals it to the caller's own derived identity, registers the group. - add_member: seals the current GCK to a member's pasted public key. - remove_member: revokes membership (server ACL). GCK rotation for forward secrecy is a later slice; noted in the doc. - my_identity_public_key: the base64 pubkey a member shares to be added. - SyncTable::group_scope() accessor so a consumer can assert which tables are group-scoped. Group id is now client-generated. The admin's GCK grant binds the group id as AEAD associated data, so the id must be known before the seal — before the server round-trip. CreateGroupRequest carries the client-chosen UUID (the PK, previously server-defaulted); create is safe to retry since a replay hits the PK with an identical grant. This removes the create-then-reseal lockout risk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 22:13 UTC
Commit: 36090963ad731612d9ca927f159000251adbbf34
Parent: a0a7e03
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.