Skip to main content

max / makenotwork

synckit-server: groups schema and membership/grant db layer (Groups phase 2, p2-tables) Server data model for shared, end-to-end-encrypted group changelogs. The server stores membership and opaque sealed GCK grants; it never sees the group key or any plaintext. - migration 171: sync_groups (admin_user_id, gck_version), sync_group_members (role, sealed_gck, gck_version), and an additive nullable sync_log.group_id column (NULL = personal, no backfill) with a partial index for the group pull path. - SyncGroupId newtype; DbSyncGroup / DbSyncGroupMember models. - db::synckit::groups: create_group (enrolls the admin in one tx), get_group, list_groups_for_user, list_members, is_group_member, is_group_admin, get_member_grant, add_or_update_member (idempotent upsert), remove_member, and rotate_group_gck (bump version, drop the removed set, re-grant, atomic). Group-scoped push/pull, route gating, and integration tests land in p2-changelog. Design: wiki synckit-groups-design. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-23 18:07 UTC
Commit: d7600116ec4a137a1e2430ae5851973c002c3cc9
Parent: 0a7c05d
5 files changed, +369 insertions, -0 deletions
@@ -0,0 +1,54 @@
1 + -- SyncKit Groups: shared, end-to-end-encrypted changelogs across multiple users.
2 + --
3 + -- Schema only (phase 2 / p2-tables). The group-scoped push/pull and membership
4 + -- gating that USE the `group_id` column below land in p2-changelog. The server
5 + -- never sees the Group Content Key (GCK) or any plaintext — it stores opaque
6 + -- sealed grants and ciphertext only. Design: wiki synckit-groups-design.
7 +
8 + -- A group: a shared changelog owned by one admin, whose members each hold a
9 + -- grant of the same GCK sealed to their identity public key.
10 + CREATE TABLE sync_groups (
11 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
12 + app_id UUID NOT NULL REFERENCES sync_apps(id) ON DELETE CASCADE,
13 + -- The admin who mints the GCK and manages membership. The group's changelog
14 + -- and blob storage bill against this user's slot (admin's slot, 2026-07-23).
15 + admin_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
16 + name VARCHAR(100) NOT NULL,
17 + -- Current GCK generation. Bumped when a member is removed; rotation re-seals
18 + -- the new GCK to the remaining members. Grants carry the version they were
19 + -- sealed for, so a stale grant is detectable.
20 + gck_version INT NOT NULL DEFAULT 1,
21 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
22 + );
23 +
24 + CREATE INDEX idx_sync_groups_app_admin ON sync_groups (app_id, admin_user_id);
25 +
26 + -- Membership + the per-member sealed GCK grant. One row per (group, user).
27 + CREATE TABLE sync_group_members (
28 + group_id UUID NOT NULL REFERENCES sync_groups(id) ON DELETE CASCADE,
29 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
30 + -- Reserved for the later per-key permission system. MVP is admin | member;
31 + -- every member reads and writes.
32 + role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
33 + -- The GCK sealed to this member's X25519 public key (base64), opaque to the
34 + -- server. Produced by the admin via seal_gck_to_member (synckit-client).
35 + sealed_gck TEXT NOT NULL,
36 + -- The GCK generation `sealed_gck` was sealed under. Matches
37 + -- sync_groups.gck_version while current; a lower value marks a stale grant.
38 + gck_version INT NOT NULL,
39 + added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
40 + PRIMARY KEY (group_id, user_id)
41 + );
42 +
43 + -- "Which groups am I in" is a per-user lookup on pull/list.
44 + CREATE INDEX idx_sync_group_members_user ON sync_group_members (user_id);
45 +
46 + -- Group scope on the changelog. NULL = personal: the existing per-user semantics
47 + -- are untouched, so no backfill is needed. A group entry is scoped by
48 + -- (app_id, group_id); user_id still records the pushing user for provenance.
49 + -- Consumed by p2-changelog.
50 + ALTER TABLE sync_log ADD COLUMN group_id UUID REFERENCES sync_groups(id) ON DELETE CASCADE;
51 +
52 + -- Partial index for the group pull path; personal pulls keep using the existing
53 + -- (app_id, user_id, seq) access pattern and never touch this index.
54 + CREATE INDEX idx_sync_log_group ON sync_log (app_id, group_id, seq) WHERE group_id IS NOT NULL;
@@ -152,6 +152,7 @@ define_pg_uuid_id!(
152 152 SyncAppId,
153 153 SyncDeviceId,
154 154 SyncBlobId,
155 + SyncGroupId,
155 156 LoginTokenId,
156 157 OAuthCodeId,
157 158 OAuthRefreshTokenId,
@@ -93,6 +93,44 @@ pub struct DbSyncKeyRotation {
93 93 pub updated_at: DateTime<Utc>,
94 94 }
95 95
96 + /// A SyncKit group: a shared, end-to-end-encrypted changelog owned by one admin,
97 + /// whose members each hold the group's GCK sealed to their identity key.
98 + #[derive(Debug, Clone, FromRow, Serialize)]
99 + pub struct DbSyncGroup {
100 + /// Database primary key.
101 + pub id: SyncGroupId,
102 + /// Sync app this group belongs to.
103 + pub app_id: SyncAppId,
104 + /// The admin who mints the GCK and manages membership; bears the storage bill.
105 + pub admin_user_id: UserId,
106 + /// Human-readable group name.
107 + pub name: String,
108 + /// Current GCK generation. Bumped on member removal (rotation).
109 + pub gck_version: i32,
110 + /// When the group was created.
111 + pub created_at: DateTime<Utc>,
112 + }
113 +
114 + /// One member of a group, carrying that member's sealed GCK grant.
115 + #[derive(Debug, Clone, FromRow, Serialize)]
116 + pub struct DbSyncGroupMember {
117 + /// The group this membership belongs to.
118 + pub group_id: SyncGroupId,
119 + /// The member.
120 + pub user_id: UserId,
121 + /// `admin` | `member`. Reserved for the later per-key permission system; MVP
122 + /// treats every member as a reader and writer.
123 + pub role: String,
124 + /// The GCK sealed to this member's X25519 public key (base64), opaque to the
125 + /// server. The member opens it with their identity private key.
126 + pub sealed_gck: String,
127 + /// The GCK generation `sealed_gck` was sealed under; a value below the
128 + /// group's current `gck_version` marks a stale grant.
129 + pub gck_version: i32,
130 + /// When this member was added.
131 + pub added_at: DateTime<Utc>,
132 + }
133 +
96 134 /// A blob uploaded to S3 via SyncKit, tracked for dedup and cleanup.
97 135 #[derive(Debug, Clone, FromRow, Serialize)]
98 136 pub struct DbSyncBlob {
@@ -0,0 +1,274 @@
1 + //! SyncKit groups: the shared-changelog membership and sealed-GCK-grant layer.
2 + //!
3 + //! A group is owned by one admin who mints a Group Content Key (GCK) and seals it
4 + //! to each member's identity public key. The server stores membership and the
5 + //! opaque sealed grants; it never sees the GCK or any plaintext. The group-scoped
6 + //! push/pull that reads `sync_log.group_id` lives in `log.rs` (p2-changelog); this
7 + //! module owns the group, membership, and grant records. Design: wiki
8 + //! synckit-groups-design.
9 +
10 + use sqlx::PgPool;
11 +
12 + use crate::db::models::*;
13 + use crate::db::{SyncAppId, SyncGroupId, UserId};
14 + use crate::error::Result;
15 +
16 + /// Create a group and enroll its admin as the first member.
17 + ///
18 + /// Runs in one transaction: the `sync_groups` row (GCK generation 1) and the
19 + /// admin's own `sync_group_members` row (`role = 'admin'`, carrying the admin's
20 + /// self-sealed GCK grant) are written together, so a group never exists without
21 + /// its admin able to read it.
22 + #[tracing::instrument(skip_all)]
23 + pub async fn create_group(
24 + pool: &PgPool,
25 + app_id: SyncAppId,
26 + admin_user_id: UserId,
27 + name: &str,
28 + admin_sealed_gck: &str,
29 + ) -> Result<DbSyncGroup> {
30 + let mut tx = pool.begin().await?;
31 +
32 + let group = sqlx::query_as::<_, DbSyncGroup>(
33 + r#"
34 + INSERT INTO sync_groups (app_id, admin_user_id, name)
35 + VALUES ($1, $2, $3)
36 + RETURNING id, app_id, admin_user_id, name, gck_version, created_at
37 + "#,
38 + )
39 + .bind(app_id)
40 + .bind(admin_user_id)
41 + .bind(name)
42 + .fetch_one(&mut *tx)
43 + .await?;
44 +
45 + sqlx::query(
46 + r#"
47 + INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version)
48 + VALUES ($1, $2, 'admin', $3, $4)
49 + "#,
50 + )
51 + .bind(group.id)
52 + .bind(admin_user_id)
53 + .bind(admin_sealed_gck)
54 + .bind(group.gck_version)
55 + .execute(&mut *tx)
56 + .await?;
57 +
58 + tx.commit().await?;
59 + Ok(group)
60 + }
61 +
62 + /// Fetch a group scoped to its app (the app scope guards against cross-app id
63 + /// guessing).
64 + #[tracing::instrument(skip_all)]
65 + pub async fn get_group(
66 + pool: &PgPool,
67 + app_id: SyncAppId,
68 + group_id: SyncGroupId,
69 + ) -> Result<Option<DbSyncGroup>> {
70 + let group = sqlx::query_as::<_, DbSyncGroup>(
71 + r#"
72 + SELECT id, app_id, admin_user_id, name, gck_version, created_at
73 + FROM sync_groups
74 + WHERE app_id = $1 AND id = $2
75 + "#,
76 + )
77 + .bind(app_id)
78 + .bind(group_id)
79 + .fetch_optional(pool)
80 + .await?;
81 + Ok(group)
82 + }
83 +
84 + /// List the groups a user belongs to within an app, most recent first.
85 + #[tracing::instrument(skip_all)]
86 + pub async fn list_groups_for_user(
87 + pool: &PgPool,
88 + app_id: SyncAppId,
89 + user_id: UserId,
90 + ) -> Result<Vec<DbSyncGroup>> {
91 + let groups = sqlx::query_as::<_, DbSyncGroup>(
92 + r#"
93 + SELECT g.id, g.app_id, g.admin_user_id, g.name, g.gck_version, g.created_at
94 + FROM sync_groups g
95 + JOIN sync_group_members m ON m.group_id = g.id
96 + WHERE g.app_id = $1 AND m.user_id = $2
97 + ORDER BY g.created_at DESC
98 + "#,
99 + )
100 + .bind(app_id)
101 + .bind(user_id)
102 + .fetch_all(pool)
103 + .await?;
104 + Ok(groups)
105 + }
106 +
107 + /// All members of a group.
108 + #[tracing::instrument(skip_all)]
109 + pub async fn list_members(pool: &PgPool, group_id: SyncGroupId) -> Result<Vec<DbSyncGroupMember>> {
110 + let members = sqlx::query_as::<_, DbSyncGroupMember>(
111 + r#"
112 + SELECT group_id, user_id, role, sealed_gck, gck_version, added_at
113 + FROM sync_group_members
114 + WHERE group_id = $1
115 + ORDER BY added_at ASC
116 + "#,
117 + )
118 + .bind(group_id)
119 + .fetch_all(pool)
120 + .await?;
121 + Ok(members)
122 + }
123 +
124 + /// Whether `user_id` is a member of `group_id`. The membership gate for
125 + /// group-scoped push/pull.
126 + #[tracing::instrument(skip_all)]
127 + pub async fn is_group_member(
128 + pool: &PgPool,
129 + group_id: SyncGroupId,
130 + user_id: UserId,
131 + ) -> Result<bool> {
132 + let exists: bool = sqlx::query_scalar(
133 + "SELECT EXISTS (SELECT 1 FROM sync_group_members WHERE group_id = $1 AND user_id = $2)",
134 + )
135 + .bind(group_id)
136 + .bind(user_id)
137 + .fetch_one(pool)
138 + .await?;
139 + Ok(exists)
140 + }
141 +
142 + /// Whether `user_id` is the admin of `group_id`. Admin-only actions (add/remove
143 + /// member, rotate) gate on this.
144 + #[tracing::instrument(skip_all)]
145 + pub async fn is_group_admin(pool: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<bool> {
146 + let exists: bool = sqlx::query_scalar(
147 + "SELECT EXISTS (SELECT 1 FROM sync_groups WHERE id = $1 AND admin_user_id = $2)",
148 + )
149 + .bind(group_id)
150 + .bind(user_id)
151 + .fetch_one(pool)
152 + .await?;
153 + Ok(exists)
154 + }
155 +
156 + /// Fetch a member's current sealed GCK grant `(sealed_gck, gck_version)`, so the
157 + /// member's device can open the GCK and read the group changelog. `None` if the
158 + /// user is not a member.
159 + #[tracing::instrument(skip_all)]
160 + pub async fn get_member_grant(
161 + pool: &PgPool,
162 + group_id: SyncGroupId,
163 + user_id: UserId,
164 + ) -> Result<Option<(String, i32)>> {
165 + let grant: Option<(String, i32)> = sqlx::query_as(
166 + "SELECT sealed_gck, gck_version FROM sync_group_members WHERE group_id = $1 AND user_id = $2",
167 + )
168 + .bind(group_id)
169 + .bind(user_id)
170 + .fetch_optional(pool)
171 + .await?;
172 + Ok(grant)
173 + }
174 +
175 + /// Add a member, or replace an existing member's grant (idempotent upsert).
176 + ///
177 + /// The admin calls this with a grant it sealed to the member's public key at the
178 + /// group's current `gck_version`. Re-adding an existing member updates their
179 + /// grant and role in place.
180 + #[tracing::instrument(skip_all)]
181 + pub async fn add_or_update_member(
182 + pool: &PgPool,
183 + group_id: SyncGroupId,
184 + user_id: UserId,
185 + role: &str,
186 + sealed_gck: &str,
187 + gck_version: i32,
188 + ) -> Result<()> {
189 + sqlx::query(
190 + r#"
191 + INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version)
192 + VALUES ($1, $2, $3, $4, $5)
193 + ON CONFLICT (group_id, user_id)
194 + DO UPDATE SET role = EXCLUDED.role,
195 + sealed_gck = EXCLUDED.sealed_gck,
196 + gck_version = EXCLUDED.gck_version
197 + "#,
198 + )
199 + .bind(group_id)
200 + .bind(user_id)
201 + .bind(role)
202 + .bind(sealed_gck)
203 + .bind(gck_version)
204 + .execute(pool)
205 + .await?;
206 + Ok(())
207 + }
208 +
209 + /// Remove a member. Returns `true` if a membership row was deleted.
210 + ///
211 + /// This drops the member's access to future group writes; forward secrecy for
212 + /// writes after removal comes from the caller then rotating the GCK
213 + /// ([`rotate_group_gck`]). Data the removed member already pulled is,
214 + /// unavoidably, already in their hands.
215 + #[tracing::instrument(skip_all)]
216 + pub async fn remove_member(pool: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<bool> {
217 + let result = sqlx::query("DELETE FROM sync_group_members WHERE group_id = $1 AND user_id = $2")
218 + .bind(group_id)
219 + .bind(user_id)
220 + .execute(pool)
221 + .await?;
222 + Ok(result.rows_affected() > 0)
223 + }
224 +
225 + /// Rotate the group's GCK to `new_version`, atomically replacing every member's
226 + /// grant with one sealed under the new key.
227 + ///
228 + /// The admin mints a fresh GCK, seals it to the remaining members' stored public
229 + /// keys, and passes `(user_id, sealed_gck)` for each. In one transaction this
230 + /// bumps `sync_groups.gck_version`, deletes any member not in `grants` (the
231 + /// removed set), and upserts each provided grant at `new_version`. Callers must
232 + /// include the admin's own re-sealed grant.
233 + #[tracing::instrument(skip_all)]
234 + pub async fn rotate_group_gck(
235 + pool: &PgPool,
236 + group_id: SyncGroupId,
237 + new_version: i32,
238 + grants: &[(UserId, String)],
239 + ) -> Result<()> {
240 + let mut tx = pool.begin().await?;
241 +
242 + sqlx::query("UPDATE sync_groups SET gck_version = $1 WHERE id = $2")
243 + .bind(new_version)
244 + .bind(group_id)
245 + .execute(&mut *tx)
246 + .await?;
247 +
248 + // Anyone not in the new grant set is removed by the rotation.
249 + let keep: Vec<UserId> = grants.iter().map(|(u, _)| *u).collect();
250 + sqlx::query("DELETE FROM sync_group_members WHERE group_id = $1 AND user_id <> ALL($2)")
251 + .bind(group_id)
252 + .bind(&keep)
253 + .execute(&mut *tx)
254 + .await?;
255 +
256 + for (user_id, sealed_gck) in grants {
257 + sqlx::query(
258 + r#"
259 + UPDATE sync_group_members
260 + SET sealed_gck = $3, gck_version = $4
261 + WHERE group_id = $1 AND user_id = $2
262 + "#,
263 + )
264 + .bind(group_id)
265 + .bind(user_id)
266 + .bind(sealed_gck)
267 + .bind(new_version)
268 + .execute(&mut *tx)
269 + .await?;
270 + }
271 +
272 + tx.commit().await?;
273 + Ok(())
274 + }
@@ -7,6 +7,7 @@
7 7 mod apps;
8 8 mod blobs;
9 9 mod devices;
10 + mod groups;
10 11 mod keys;
11 12 mod log;
12 13 mod rotation;
@@ -16,6 +17,7 @@ mod subscriptions;
16 17 pub use apps::*;
17 18 pub use blobs::*;
18 19 pub use devices::*;
20 + pub use groups::*;
19 21 pub use keys::*;
20 22 pub use log::*;
21 23 pub use rotation::*;