max / makenotwork
9 files changed,
+1245 insertions,
-7 deletions
| @@ -7,7 +7,7 @@ | |||
| 7 | 7 | ||
| 8 | 8 | -- A group: a shared changelog owned by one admin, whose members each hold a | |
| 9 | 9 | -- grant of the same GCK sealed to their identity public key. | |
| 10 | - | CREATE TABLE sync_groups ( | |
| 10 | + | CREATE TABLE IF NOT EXISTS sync_groups ( | |
| 11 | 11 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 12 | 12 | app_id UUID NOT NULL REFERENCES sync_apps(id) ON DELETE CASCADE, | |
| 13 | 13 | -- The admin who mints the GCK and manages membership. The group's changelog | |
| @@ -21,10 +21,10 @@ CREATE TABLE sync_groups ( | |||
| 21 | 21 | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 22 | 22 | ); | |
| 23 | 23 | ||
| 24 | - | CREATE INDEX idx_sync_groups_app_admin ON sync_groups (app_id, admin_user_id); | |
| 24 | + | CREATE INDEX IF NOT EXISTS idx_sync_groups_app_admin ON sync_groups (app_id, admin_user_id); | |
| 25 | 25 | ||
| 26 | 26 | -- Membership + the per-member sealed GCK grant. One row per (group, user). | |
| 27 | - | CREATE TABLE sync_group_members ( | |
| 27 | + | CREATE TABLE IF NOT EXISTS sync_group_members ( | |
| 28 | 28 | group_id UUID NOT NULL REFERENCES sync_groups(id) ON DELETE CASCADE, | |
| 29 | 29 | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 30 | 30 | -- Reserved for the later per-key permission system. MVP is admin | member; | |
| @@ -41,7 +41,7 @@ CREATE TABLE sync_group_members ( | |||
| 41 | 41 | ); | |
| 42 | 42 | ||
| 43 | 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); | |
| 44 | + | CREATE INDEX IF NOT EXISTS idx_sync_group_members_user ON sync_group_members (user_id); | |
| 45 | 45 | ||
| 46 | 46 | -- Group scope on the changelog. NULL = personal: the existing per-user semantics | |
| 47 | 47 | -- are untouched, so no backfill is needed. A group entry is scoped by | |
| @@ -51,4 +51,4 @@ ALTER TABLE sync_log ADD COLUMN group_id UUID REFERENCES sync_groups(id) ON DELE | |||
| 51 | 51 | ||
| 52 | 52 | -- Partial index for the group pull path; personal pulls keep using the existing | |
| 53 | 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; | |
| 54 | + | CREATE INDEX IF NOT EXISTS idx_sync_log_group ON sync_log (app_id, group_id, seq) WHERE group_id IS NOT NULL; |
| @@ -0,0 +1,39 @@ | |||
| 1 | + | -- SyncKit Groups: give the group changelog its own table. | |
| 2 | + | -- | |
| 3 | + | -- Supersedes the sync_log.group_id column added in 171. Keeping group entries in | |
| 4 | + | -- sync_log meant every personal-scope query (pull, key rotation, status, cleanup) | |
| 5 | + | -- had to remember `AND group_id IS NULL` or silently leak GCK-encrypted group rows | |
| 6 | + | -- into a user's personal sync -- and let personal key rotation re-encrypt them with | |
| 7 | + | -- the wrong key. A dedicated table makes that isolation structural: sync_log stays | |
| 8 | + | -- purely personal and unchanged, and the group changelog evolves on its own. | |
| 9 | + | -- Design: wiki synckit-groups-design. | |
| 10 | + | ||
| 11 | + | DROP INDEX IF EXISTS idx_sync_log_group; | |
| 12 | + | ALTER TABLE sync_log DROP COLUMN IF EXISTS group_id; | |
| 13 | + | ||
| 14 | + | CREATE TABLE IF NOT EXISTS sync_group_log ( | |
| 15 | + | seq BIGSERIAL PRIMARY KEY, | |
| 16 | + | app_id UUID NOT NULL REFERENCES sync_apps(id) ON DELETE CASCADE, | |
| 17 | + | group_id UUID NOT NULL REFERENCES sync_groups(id) ON DELETE CASCADE, | |
| 18 | + | -- The member who pushed this change (provenance/audit). The group, not the | |
| 19 | + | -- user, owns the entry, so pulls are scoped by group_id, not user_id. | |
| 20 | + | user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 21 | + | device_id UUID NOT NULL REFERENCES sync_devices(id) ON DELETE CASCADE, | |
| 22 | + | -- Client-generated batch id for idempotent push (same role as sync_log). | |
| 23 | + | batch_id UUID, | |
| 24 | + | table_name VARCHAR(100) NOT NULL, | |
| 25 | + | operation VARCHAR(10) NOT NULL | |
| 26 | + | CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')), | |
| 27 | + | row_id VARCHAR(255) NOT NULL, | |
| 28 | + | client_timestamp TIMESTAMPTZ NOT NULL, | |
| 29 | + | -- Ciphertext sealed under the group's GCK (generation = sync_groups.gck_version). | |
| 30 | + | -- No key_id column: group entries key off the GCK, not the per-user | |
| 31 | + | -- sync_keys.key_id that personal rotation tracks. | |
| 32 | + | data JSONB, | |
| 33 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 34 | + | ); | |
| 35 | + | ||
| 36 | + | -- Group pull: (app_id, group_id, seq > cursor) ordered by seq. | |
| 37 | + | CREATE INDEX IF NOT EXISTS idx_sync_group_log_pull ON sync_group_log (app_id, group_id, seq); | |
| 38 | + | -- Group push idempotency: MAX(seq) for a (group, batch). | |
| 39 | + | CREATE INDEX IF NOT EXISTS idx_sync_group_log_batch ON sync_group_log (group_id, batch_id); |
| @@ -131,6 +131,38 @@ pub struct DbSyncGroupMember { | |||
| 131 | 131 | pub added_at: DateTime<Utc>, | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | + | /// An entry in a group's append-only shared change log (`sync_group_log`). | |
| 135 | + | /// | |
| 136 | + | /// The group changelog is a separate table from the personal [`DbSyncLogEntry`] | |
| 137 | + | /// so personal-scope queries can never see group rows (which are sealed under the | |
| 138 | + | /// group's GCK, not the per-user key). There is deliberately no `key_id`: a group | |
| 139 | + | /// entry's key generation is the group's `gck_version`, not `sync_keys.key_id`. | |
| 140 | + | #[derive(Debug, Clone, FromRow, Serialize)] | |
| 141 | + | pub struct DbSyncGroupLogEntry { | |
| 142 | + | /// Server-assigned monotonic sequence number; the group pull cursor. | |
| 143 | + | pub seq: i64, | |
| 144 | + | /// Sync app this entry belongs to. | |
| 145 | + | pub app_id: SyncAppId, | |
| 146 | + | /// The group whose shared log this entry is in. | |
| 147 | + | pub group_id: SyncGroupId, | |
| 148 | + | /// The member who pushed this change (provenance). | |
| 149 | + | pub user_id: UserId, | |
| 150 | + | /// Device that originated this change. | |
| 151 | + | pub device_id: SyncDeviceId, | |
| 152 | + | /// Opaque table name from the client. | |
| 153 | + | pub table_name: String, | |
| 154 | + | /// Type of change (insert, update, or delete). | |
| 155 | + | pub operation: super::super::SyncOperation, | |
| 156 | + | /// Client-side row identifier (opaque to the server). | |
| 157 | + | pub row_id: String, | |
| 158 | + | /// Timestamp assigned by the client when the change was made. | |
| 159 | + | pub client_timestamp: DateTime<Utc>, | |
| 160 | + | /// Encrypted row data (sealed under the group GCK). Null for deletes. | |
| 161 | + | pub data: Option<serde_json::Value>, | |
| 162 | + | /// When the server received and recorded this entry. | |
| 163 | + | pub created_at: DateTime<Utc>, | |
| 164 | + | } | |
| 165 | + | ||
| 134 | 166 | /// A blob uploaded to S3 via SyncKit, tracked for dedup and cleanup. | |
| 135 | 167 | #[derive(Debug, Clone, FromRow, Serialize)] | |
| 136 | 168 | pub struct DbSyncBlob { |
| @@ -6,7 +6,7 @@ use serde_json::Value as JsonValue; | |||
| 6 | 6 | use sqlx::PgPool; | |
| 7 | 7 | ||
| 8 | 8 | use crate::db::models::*; | |
| 9 | - | use crate::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 9 | + | use crate::db::{SyncAppId, SyncDeviceId, SyncGroupId, UserId}; | |
| 10 | 10 | use crate::error::Result; | |
| 11 | 11 | ||
| 12 | 12 | // ── Sync Log ── | |
| @@ -129,6 +129,140 @@ pub async fn push_sync_changes( | |||
| 129 | 129 | Ok(max_seq) | |
| 130 | 130 | } | |
| 131 | 131 | ||
| 132 | + | /// Push a batch of changes to a **group's** shared changelog (`sync_group_log`). | |
| 133 | + | /// Returns the highest seq assigned. The group scope (not the pushing user) owns | |
| 134 | + | /// the entries, so every member's pull sees them. | |
| 135 | + | /// | |
| 136 | + | /// Mirrors [`push_sync_changes`] but writes the separate `sync_group_log` table | |
| 137 | + | /// and scopes idempotency by `(app_id, group_id, batch_id)`. Group entries carry | |
| 138 | + | /// no `key_id`: their key generation is the group's `gck_version`, not the | |
| 139 | + | /// per-user `sync_keys.key_id`. The dedicated table is what keeps group rows out | |
| 140 | + | /// of every personal-scope query with no `group_id IS NULL` guard to remember. | |
| 141 | + | /// Membership is enforced by the caller. | |
| 142 | + | #[allow(clippy::type_complexity)] | |
| 143 | + | #[tracing::instrument(skip_all)] | |
| 144 | + | pub async fn push_group_changes( | |
| 145 | + | pool: &PgPool, | |
| 146 | + | app_id: SyncAppId, | |
| 147 | + | group_id: SyncGroupId, | |
| 148 | + | user_id: UserId, | |
| 149 | + | device_id: SyncDeviceId, | |
| 150 | + | batch_id: uuid::Uuid, | |
| 151 | + | changes: &[( | |
| 152 | + | String, | |
| 153 | + | String, | |
| 154 | + | String, | |
| 155 | + | chrono::DateTime<chrono::Utc>, | |
| 156 | + | Option<JsonValue>, | |
| 157 | + | )], | |
| 158 | + | ) -> Result<i64> { | |
| 159 | + | if changes.is_empty() { | |
| 160 | + | return Ok(0); | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | let mut table_names: Vec<String> = Vec::with_capacity(changes.len()); | |
| 164 | + | let mut operations: Vec<String> = Vec::with_capacity(changes.len()); | |
| 165 | + | let mut row_ids: Vec<String> = Vec::with_capacity(changes.len()); | |
| 166 | + | let mut client_timestamps: Vec<chrono::DateTime<chrono::Utc>> = | |
| 167 | + | Vec::with_capacity(changes.len()); | |
| 168 | + | let mut data_values: Vec<JsonValue> = Vec::with_capacity(changes.len()); | |
| 169 | + | ||
| 170 | + | for (table_name, operation, row_id, client_timestamp, data) in changes { | |
| 171 | + | table_names.push(table_name.clone()); | |
| 172 | + | operations.push(operation.clone()); | |
| 173 | + | row_ids.push(row_id.clone()); | |
| 174 | + | client_timestamps.push(*client_timestamp); | |
| 175 | + | data_values.push(data.clone().unwrap_or(JsonValue::Null)); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | let mut tx = pool.begin().await?; | |
| 179 | + | ||
| 180 | + | // Serialize concurrent redeliveries of the same batch within this group, so | |
| 181 | + | // the MAX(seq) idempotency check below is race-free (same pattern as the | |
| 182 | + | // per-user push lock, keyed by group instead of user). | |
| 183 | + | sqlx::query( | |
| 184 | + | "SELECT pg_advisory_xact_lock(hashtextextended('synckit_group_push:' || $1::text || ':' || $2::text, 0))", | |
| 185 | + | ) | |
| 186 | + | .bind(group_id) | |
| 187 | + | .bind(batch_id) | |
| 188 | + | .execute(&mut *tx) | |
| 189 | + | .await?; | |
| 190 | + | ||
| 191 | + | let existing: (Option<i64>,) = sqlx::query_as( | |
| 192 | + | "SELECT MAX(seq) FROM sync_group_log WHERE app_id = $1 AND group_id = $2 AND batch_id = $3", | |
| 193 | + | ) | |
| 194 | + | .bind(app_id) | |
| 195 | + | .bind(group_id) | |
| 196 | + | .bind(batch_id) | |
| 197 | + | .fetch_one(&mut *tx) | |
| 198 | + | .await?; | |
| 199 | + | ||
| 200 | + | if let Some(max_seq) = existing.0 { | |
| 201 | + | tx.rollback().await.ok(); | |
| 202 | + | return Ok(max_seq); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | let seqs: Vec<i64> = sqlx::query_scalar( | |
| 206 | + | r#" | |
| 207 | + | INSERT INTO sync_group_log (app_id, user_id, device_id, group_id, batch_id, table_name, operation, row_id, client_timestamp, data) | |
| 208 | + | SELECT $1, $2, $3, $4, $5, t.* | |
| 209 | + | FROM UNNEST($6::text[], $7::text[], $8::text[], $9::timestamptz[], $10::jsonb[]) AS t | |
| 210 | + | RETURNING seq | |
| 211 | + | "#, | |
| 212 | + | ) | |
| 213 | + | .bind(app_id) | |
| 214 | + | .bind(user_id) | |
| 215 | + | .bind(device_id) | |
| 216 | + | .bind(group_id) | |
| 217 | + | .bind(batch_id) | |
| 218 | + | .bind(&table_names) | |
| 219 | + | .bind(&operations) | |
| 220 | + | .bind(&row_ids) | |
| 221 | + | .bind(&client_timestamps) | |
| 222 | + | .bind(&data_values) | |
| 223 | + | .fetch_all(&mut *tx) | |
| 224 | + | .await?; | |
| 225 | + | ||
| 226 | + | tx.commit().await?; | |
| 227 | + | ||
| 228 | + | Ok(seqs.iter().copied().max().unwrap_or(0)) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | /// Pull a group's changes since a cursor from `sync_group_log`, with the same | |
| 232 | + | /// optional table/timestamp filters as [`pull_sync_changes_filtered`], scoped by | |
| 233 | + | /// `(app_id, group_id)`. Membership is enforced by the caller. | |
| 234 | + | #[tracing::instrument(skip_all)] | |
| 235 | + | pub async fn pull_group_changes_filtered( | |
| 236 | + | pool: &PgPool, | |
| 237 | + | app_id: SyncAppId, | |
| 238 | + | group_id: SyncGroupId, | |
| 239 | + | cursor: i64, | |
| 240 | + | limit: i64, | |
| 241 | + | tables: Option<&[String]>, | |
| 242 | + | since: Option<chrono::DateTime<chrono::Utc>>, | |
| 243 | + | ) -> Result<Vec<DbSyncGroupLogEntry>> { | |
| 244 | + | let entries = sqlx::query_as::<_, DbSyncGroupLogEntry>( | |
| 245 | + | r#" | |
| 246 | + | SELECT * FROM sync_group_log | |
| 247 | + | WHERE app_id = $1 AND group_id = $2 AND seq > $3 | |
| 248 | + | AND ($5::text[] IS NULL OR table_name = ANY($5)) | |
| 249 | + | AND ($6::timestamptz IS NULL OR client_timestamp >= $6) | |
| 250 | + | ORDER BY seq ASC | |
| 251 | + | LIMIT $4 | |
| 252 | + | "#, | |
| 253 | + | ) | |
| 254 | + | .bind(app_id) | |
| 255 | + | .bind(group_id) | |
| 256 | + | .bind(cursor) | |
| 257 | + | .bind(limit) | |
| 258 | + | .bind(tables) | |
| 259 | + | .bind(since) | |
| 260 | + | .fetch_all(pool) | |
| 261 | + | .await?; | |
| 262 | + | ||
| 263 | + | Ok(entries) | |
| 264 | + | } | |
| 265 | + | ||
| 132 | 266 | /// Pull changes since a cursor for a user within an app. | |
| 133 | 267 | /// | |
| 134 | 268 | /// Prefer `pull_sync_changes_filtered` for new code; it supports optional |
| @@ -0,0 +1,412 @@ | |||
| 1 | + | //! SyncKit group management and group-scoped push/pull. | |
| 2 | + | //! | |
| 3 | + | //! A group is a shared, end-to-end-encrypted changelog. The admin mints a Group | |
| 4 | + | //! Content Key (GCK) client-side and seals it to each member's identity public | |
| 5 | + | //! key; the server stores membership and the opaque sealed grants and never sees | |
| 6 | + | //! the GCK or any plaintext. Group push/pull are gated on membership; management | |
| 7 | + | //! actions (add/remove member) are gated on being the group admin. | |
| 8 | + | //! | |
| 9 | + | //! Deferred to later slices (noted where they'd hook in): the paid-write gate and | |
| 10 | + | //! SSE push notifications for groups (p2-billing), member public-key storage and | |
| 11 | + | //! GCK rotation (p3). Design: wiki synckit-groups-design. | |
| 12 | + | ||
| 13 | + | use axum::{ | |
| 14 | + | Json, | |
| 15 | + | extract::{Path, State}, | |
| 16 | + | http::StatusCode, | |
| 17 | + | response::IntoResponse, | |
| 18 | + | }; | |
| 19 | + | use sqlx::PgPool; | |
| 20 | + | ||
| 21 | + | use crate::{ | |
| 22 | + | constants, | |
| 23 | + | db::{self, DbSyncGroup, SyncGroupId, UserId}, | |
| 24 | + | error::{AppError, Result}, | |
| 25 | + | synckit_auth::SyncUser, | |
| 26 | + | validation, | |
| 27 | + | }; | |
| 28 | + | ||
| 29 | + | use super::{ | |
| 30 | + | AddMemberRequest, CreateGroupRequest, GroupGrantResponse, GroupMemberResponse, GroupResponse, | |
| 31 | + | PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse, | |
| 32 | + | }; | |
| 33 | + | ||
| 34 | + | /// Fetch a group scoped to the caller's app, or 404. Guards every group handler | |
| 35 | + | /// against cross-app id guessing before any membership check. | |
| 36 | + | async fn require_group( | |
| 37 | + | db: &PgPool, | |
| 38 | + | app_id: db::SyncAppId, | |
| 39 | + | group_id: SyncGroupId, | |
| 40 | + | ) -> Result<DbSyncGroup> { | |
| 41 | + | db::synckit::get_group(db, app_id, group_id) | |
| 42 | + | .await? | |
| 43 | + | .ok_or(AppError::NotFound) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Reject a caller who is not a member of the group (403). The gate for | |
| 47 | + | /// group-scoped reads and writes. | |
| 48 | + | async fn require_member(db: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<()> { | |
| 49 | + | if db::synckit::is_group_member(db, group_id, user_id).await? { | |
| 50 | + | Ok(()) | |
| 51 | + | } else { | |
| 52 | + | Err(AppError::Forbidden) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Reject a caller who is not the group admin (403). The gate for membership | |
| 57 | + | /// management. | |
| 58 | + | async fn require_admin(db: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<()> { | |
| 59 | + | if db::synckit::is_group_admin(db, group_id, user_id).await? { | |
| 60 | + | Ok(()) | |
| 61 | + | } else { | |
| 62 | + | Err(AppError::Forbidden) | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Create a group. The caller becomes its admin and first member, carrying the | |
| 67 | + | /// GCK they sealed to their own identity key. | |
| 68 | + | #[utoipa::path(post, path = "/api/v1/sync/groups", tag = "SyncKit", | |
| 69 | + | request_body = CreateGroupRequest, | |
| 70 | + | responses((status = 200, description = "Created group", body = GroupResponse)), | |
| 71 | + | security(("bearer" = [])), | |
| 72 | + | )] | |
| 73 | + | #[tracing::instrument(skip_all, name = "synckit::create_group")] | |
| 74 | + | pub(super) async fn create_group( | |
| 75 | + | State(db): State<PgPool>, | |
| 76 | + | sync_user: SyncUser, | |
| 77 | + | Json(req): Json<CreateGroupRequest>, | |
| 78 | + | ) -> Result<impl IntoResponse> { | |
| 79 | + | validation::validate_sync_group_name(&req.name)?; | |
| 80 | + | if req.admin_sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES { | |
| 81 | + | return Err(AppError::BadRequest( | |
| 82 | + | "Sealed key exceeds size limit".to_string(), | |
| 83 | + | )); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | let group = db::synckit::create_group( | |
| 87 | + | &db, | |
| 88 | + | sync_user.app_id, | |
| 89 | + | sync_user.user_id, | |
| 90 | + | &req.name, | |
| 91 | + | &req.admin_sealed_gck, | |
| 92 | + | ) | |
| 93 | + | .await?; | |
| 94 | + | ||
| 95 | + | Ok(Json(GroupResponse::from(group))) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// List the groups the caller belongs to within this app. | |
| 99 | + | #[utoipa::path(get, path = "/api/v1/sync/groups", tag = "SyncKit", | |
| 100 | + | responses((status = 200, description = "Groups the user belongs to", body = Vec<GroupResponse>)), | |
| 101 | + | security(("bearer" = [])), | |
| 102 | + | )] | |
| 103 | + | #[tracing::instrument(skip_all, name = "synckit::list_groups")] | |
| 104 | + | pub(super) async fn list_groups( | |
| 105 | + | State(db): State<PgPool>, | |
| 106 | + | sync_user: SyncUser, | |
| 107 | + | ) -> Result<impl IntoResponse> { | |
| 108 | + | let groups = | |
| 109 | + | db::synckit::list_groups_for_user(&db, sync_user.app_id, sync_user.user_id).await?; | |
| 110 | + | let response: Vec<GroupResponse> = groups.into_iter().map(GroupResponse::from).collect(); | |
| 111 | + | Ok(Json(response)) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | /// Add a member to a group (or replace their grant). Admin only. | |
| 115 | + | /// | |
| 116 | + | /// The admin resolves the member out of band, seals the current GCK to that | |
| 117 | + | /// member's public key, and posts `{member_email, sealed_gck}`. The server maps | |
| 118 | + | /// the email to a verified account and stores the opaque grant at the group's | |
| 119 | + | /// current GCK generation. | |
| 120 | + | #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/members", tag = "SyncKit", | |
| 121 | + | params(("id" = String, Path, description = "Group ID")), | |
| 122 | + | request_body = AddMemberRequest, | |
| 123 | + | responses((status = 204, description = "Member added"), (status = 403, description = "Not the group admin")), | |
| 124 | + | security(("bearer" = [])), | |
| 125 | + | )] | |
| 126 | + | #[tracing::instrument(skip_all, name = "synckit::add_group_member")] | |
| 127 | + | pub(super) async fn add_member( | |
| 128 | + | State(db): State<PgPool>, | |
| 129 | + | sync_user: SyncUser, | |
| 130 | + | Path(group_id): Path<SyncGroupId>, | |
| 131 | + | Json(req): Json<AddMemberRequest>, | |
| 132 | + | ) -> Result<impl IntoResponse> { | |
| 133 | + | let group = require_group(&db, sync_user.app_id, group_id).await?; | |
| 134 | + | require_admin(&db, group_id, sync_user.user_id).await?; | |
| 135 | + | ||
| 136 | + | if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES { | |
| 137 | + | return Err(AppError::BadRequest( | |
| 138 | + | "Sealed key exceeds size limit".to_string(), | |
| 139 | + | )); | |
| 140 | + | } | |
| 141 | + | let role = req.role.as_deref().unwrap_or("member"); | |
| 142 | + | if role != "member" && role != "admin" { | |
| 143 | + | return Err(AppError::BadRequest( | |
| 144 | + | "role must be 'member' or 'admin'".to_string(), | |
| 145 | + | )); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | let email = db::Email::new(&req.member_email) | |
| 149 | + | .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?; | |
| 150 | + | let member_id = db::users::get_verified_user_id_by_email(&db, &email) | |
| 151 | + | .await? | |
| 152 | + | .ok_or_else(|| AppError::BadRequest("No verified account for that email".to_string()))?; | |
| 153 | + | ||
| 154 | + | // The grant the admin sends is sealed under the group's current GCK, so it is | |
| 155 | + | // stored at that generation. | |
| 156 | + | db::synckit::add_or_update_member( | |
| 157 | + | &db, | |
| 158 | + | group_id, | |
| 159 | + | member_id, | |
| 160 | + | role, | |
| 161 | + | &req.sealed_gck, | |
| 162 | + | group.gck_version, | |
| 163 | + | ) | |
| 164 | + | .await?; | |
| 165 | + | ||
| 166 | + | Ok(StatusCode::NO_CONTENT) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | /// Remove a member from a group. Admin only. | |
| 170 | + | /// | |
| 171 | + | /// This drops the member from future group writes. Forward secrecy for writes | |
| 172 | + | /// after removal comes from the admin then rotating the GCK (p3); data the member | |
| 173 | + | /// already pulled is already in their hands. | |
| 174 | + | #[utoipa::path(delete, path = "/api/v1/sync/groups/{id}/members/{user_id}", tag = "SyncKit", | |
| 175 | + | params( | |
| 176 | + | ("id" = String, Path, description = "Group ID"), | |
| 177 | + | ("user_id" = String, Path, description = "Member user ID"), | |
| 178 | + | ), | |
| 179 | + | responses((status = 204, description = "Member removed"), (status = 404, description = "Not a member")), | |
| 180 | + | security(("bearer" = [])), | |
| 181 | + | )] | |
| 182 | + | #[tracing::instrument(skip_all, name = "synckit::remove_group_member")] | |
| 183 | + | pub(super) async fn remove_member( | |
| 184 | + | State(db): State<PgPool>, | |
| 185 | + | sync_user: SyncUser, | |
| 186 | + | Path((group_id, member_id)): Path<(SyncGroupId, UserId)>, | |
| 187 | + | ) -> Result<impl IntoResponse> { | |
| 188 | + | let group = require_group(&db, sync_user.app_id, group_id).await?; | |
| 189 | + | require_admin(&db, group_id, sync_user.user_id).await?; | |
| 190 | + | ||
| 191 | + | // The admin cannot remove themselves; that would orphan the group. Deleting a | |
| 192 | + | // group is a separate action (not yet exposed). | |
| 193 | + | if member_id == group.admin_user_id { | |
| 194 | + | return Err(AppError::BadRequest( | |
| 195 | + | "The group admin cannot be removed".to_string(), | |
| 196 | + | )); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | if !db::synckit::remove_member(&db, group_id, member_id).await? { | |
| 200 | + | return Err(AppError::NotFound); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | Ok(StatusCode::NO_CONTENT) | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// List a group's members (id, role, joined-at). Members only. Grants are not | |
| 207 | + | /// included — each member fetches only their own via `/grant`. | |
| 208 | + | #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/members", tag = "SyncKit", | |
| 209 | + | params(("id" = String, Path, description = "Group ID")), | |
| 210 | + | responses((status = 200, description = "Group members", body = Vec<GroupMemberResponse>)), | |
| 211 | + | security(("bearer" = [])), | |
| 212 | + | )] | |
| 213 | + | #[tracing::instrument(skip_all, name = "synckit::list_group_members")] | |
| 214 | + | pub(super) async fn list_members( | |
| 215 | + | State(db): State<PgPool>, | |
| 216 | + | sync_user: SyncUser, | |
| 217 | + | Path(group_id): Path<SyncGroupId>, | |
| 218 | + | ) -> Result<impl IntoResponse> { | |
| 219 | + | require_group(&db, sync_user.app_id, group_id).await?; | |
| 220 | + | require_member(&db, group_id, sync_user.user_id).await?; | |
| 221 | + | ||
| 222 | + | let members = db::synckit::list_members(&db, group_id).await?; | |
| 223 | + | let response: Vec<GroupMemberResponse> = members | |
| 224 | + | .into_iter() | |
| 225 | + | .map(|m| GroupMemberResponse { | |
| 226 | + | user_id: m.user_id, | |
| 227 | + | role: m.role, | |
| 228 | + | added_at: m.added_at, | |
| 229 | + | }) | |
| 230 | + | .collect(); | |
| 231 | + | Ok(Json(response)) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | /// Fetch the caller's own sealed GCK grant for a group, so their device can open | |
| 235 | + | /// the GCK and read the group changelog. Members only. | |
| 236 | + | #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/grant", tag = "SyncKit", | |
| 237 | + | params(("id" = String, Path, description = "Group ID")), | |
| 238 | + | responses( | |
| 239 | + | (status = 200, description = "The caller's sealed grant", body = GroupGrantResponse), | |
| 240 | + | (status = 403, description = "Not a member"), | |
| 241 | + | ), | |
| 242 | + | security(("bearer" = [])), | |
| 243 | + | )] | |
| 244 | + | #[tracing::instrument(skip_all, name = "synckit::get_group_grant")] | |
| 245 | + | pub(super) async fn get_grant( | |
| 246 | + | State(db): State<PgPool>, | |
| 247 | + | sync_user: SyncUser, | |
| 248 | + | Path(group_id): Path<SyncGroupId>, | |
| 249 | + | ) -> Result<impl IntoResponse> { | |
| 250 | + | require_group(&db, sync_user.app_id, group_id).await?; | |
| 251 | + | ||
| 252 | + | let (sealed_gck, gck_version) = db::synckit::get_member_grant(&db, group_id, sync_user.user_id) | |
| 253 | + | .await? | |
| 254 | + | .ok_or(AppError::Forbidden)?; | |
| 255 | + | ||
| 256 | + | Ok(Json(GroupGrantResponse { | |
| 257 | + | sealed_gck, | |
| 258 | + | gck_version, | |
| 259 | + | })) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | /// Push encrypted changes to a group's shared changelog. Members only. | |
| 263 | + | #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/push", tag = "SyncKit", | |
| 264 | + | params(("id" = String, Path, description = "Group ID")), | |
| 265 | + | request_body = PushRequest, | |
| 266 | + | responses((status = 200, description = "New cursor position", body = PushResponse)), | |
| 267 | + | security(("bearer" = [])), | |
| 268 | + | )] | |
| 269 | + | #[tracing::instrument(skip_all, name = "synckit::group_push", fields(group_id))] | |
| 270 | + | pub(super) async fn group_push( | |
| 271 | + | State(db): State<PgPool>, | |
| 272 | + | sync_user: SyncUser, | |
| 273 | + | Path(group_id): Path<SyncGroupId>, | |
| 274 | + | Json(req): Json<PushRequest>, | |
| 275 | + | ) -> Result<impl IntoResponse> { | |
| 276 | + | require_group(&db, sync_user.app_id, group_id).await?; | |
| 277 | + | require_member(&db, group_id, sync_user.user_id).await?; | |
| 278 | + | // NOTE: the paid-write gate and SSE push notification (present on personal | |
| 279 | + | // push) are attached in p2-billing; group writes are open in this slice. | |
| 280 | + | ||
| 281 | + | if req.changes.is_empty() { | |
| 282 | + | return Err(AppError::BadRequest("No changes provided".to_string())); | |
| 283 | + | } | |
| 284 | + | if req.changes.len() > constants::SYNCKIT_PUSH_MAX_CHANGES { | |
| 285 | + | return Err(AppError::BadRequest(format!( | |
| 286 | + | "Maximum {} changes per push", | |
| 287 | + | constants::SYNCKIT_PUSH_MAX_CHANGES | |
| 288 | + | ))); | |
| 289 | + | } | |
| 290 | + | for change in &req.changes { | |
| 291 | + | validation::validate_sync_table_name(&change.table)?; | |
| 292 | + | validation::validate_sync_row_id(&change.row_id)?; | |
| 293 | + | if change.op == db::SyncOperation::Delete && change.data.is_some() { | |
| 294 | + | return Err(AppError::BadRequest( | |
| 295 | + | "DELETE operations should not include data".to_string(), | |
| 296 | + | )); | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | // The pushing device must belong to the pushing user (membership is a | |
| 301 | + | // separate, group-level check above). | |
| 302 | + | if !db::synckit::sync_device_belongs(&db, req.device_id, sync_user.app_id, sync_user.user_id) | |
| 303 | + | .await? | |
| 304 | + | { | |
| 305 | + | return Err(AppError::BadRequest("Unknown device".to_string())); | |
| 306 | + | } | |
| 307 | + | db::synckit::touch_sync_device(&db, req.device_id).await?; | |
| 308 | + | ||
| 309 | + | let changes: Vec<_> = req | |
| 310 | + | .changes | |
| 311 | + | .iter() | |
| 312 | + | .map(|c| { | |
| 313 | + | ( | |
| 314 | + | c.table.clone(), | |
| 315 | + | c.op.to_string(), | |
| 316 | + | c.row_id.clone(), | |
| 317 | + | c.timestamp, | |
| 318 | + | c.data.clone(), | |
| 319 | + | ) | |
| 320 | + | }) | |
| 321 | + | .collect(); | |
| 322 | + | ||
| 323 | + | let cursor = db::synckit::push_group_changes( | |
| 324 | + | &db, | |
| 325 | + | sync_user.app_id, | |
| 326 | + | group_id, | |
| 327 | + | sync_user.user_id, | |
| 328 | + | req.device_id, | |
| 329 | + | req.batch_id, | |
| 330 | + | &changes, | |
| 331 | + | ) | |
| 332 | + | .await?; | |
| 333 | + | ||
| 334 | + | Ok(Json(PushResponse { cursor })) | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | /// Pull a group's changes after a cursor. Members only. | |
| 338 | + | #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/pull", tag = "SyncKit", | |
| 339 | + | params(("id" = String, Path, description = "Group ID")), | |
| 340 | + | request_body = PullRequest, | |
| 341 | + | responses((status = 200, description = "Changes since cursor", body = PullResponse)), | |
| 342 | + | security(("bearer" = [])), | |
| 343 | + | )] | |
| 344 | + | #[tracing::instrument(skip_all, name = "synckit::group_pull", fields(group_id))] | |
| 345 | + | pub(super) async fn group_pull( | |
| 346 | + | State(db): State<PgPool>, | |
| 347 | + | sync_user: SyncUser, | |
| 348 | + | Path(group_id): Path<SyncGroupId>, | |
| 349 | + | Json(req): Json<PullRequest>, | |
| 350 | + | ) -> Result<impl IntoResponse> { | |
| 351 | + | require_group(&db, sync_user.app_id, group_id).await?; | |
| 352 | + | require_member(&db, group_id, sync_user.user_id).await?; | |
| 353 | + | ||
| 354 | + | if !db::synckit::sync_device_belongs(&db, req.device_id, sync_user.app_id, sync_user.user_id) | |
| 355 | + | .await? | |
| 356 | + | { | |
| 357 | + | return Err(AppError::BadRequest("Unknown device".to_string())); | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | if let Some(ref tables) = req.tables { | |
| 361 | + | if tables.len() > 50 { | |
| 362 | + | return Err(AppError::BadRequest( | |
| 363 | + | "Maximum 50 table names per filter".to_string(), | |
| 364 | + | )); | |
| 365 | + | } | |
| 366 | + | for table in tables { | |
| 367 | + | validation::validate_sync_table_name(table)?; | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | let page_size = constants::SYNCKIT_PULL_PAGE_SIZE; | |
| 372 | + | let entries = db::synckit::pull_group_changes_filtered( | |
| 373 | + | &db, | |
| 374 | + | sync_user.app_id, | |
| 375 | + | group_id, | |
| 376 | + | req.cursor, | |
| 377 | + | page_size, | |
| 378 | + | req.tables.as_deref(), | |
| 379 | + | req.since, | |
| 380 | + | ) | |
| 381 | + | .await?; | |
| 382 | + | ||
| 383 | + | let has_more = entries.len() as i64 == page_size; | |
| 384 | + | let new_cursor = entries.last().map(|e| e.seq).unwrap_or(req.cursor); | |
| 385 | + | ||
| 386 | + | // Touch the device for activity, but do NOT advance the per-device personal | |
| 387 | + | // compaction cursor here — that cursor governs personal-changelog retention | |
| 388 | + | // and must not be moved by a group pull. Group changelog retention is a | |
| 389 | + | // separate concern (future work). | |
| 390 | + | db::synckit::touch_sync_device(&db, req.device_id).await?; | |
| 391 | + | ||
| 392 | + | let changes: Vec<PullChangeEntry> = entries | |
| 393 | + | .into_iter() | |
| 394 | + | .map(|e| PullChangeEntry { | |
| 395 | + | seq: e.seq, | |
| 396 | + | device_id: e.device_id, | |
| 397 | + | table: e.table_name, | |
| 398 | + | op: e.operation.to_string(), | |
| 399 | + | row_id: e.row_id, | |
| 400 | + | timestamp: e.client_timestamp, | |
| 401 | + | data: e.data, | |
| 402 | + | // Group entries key off the group's GCK, not a per-user key_id. | |
| 403 | + | key_id: None, | |
| 404 | + | }) | |
| 405 | + | .collect(); | |
| 406 | + | ||
| 407 | + | Ok(Json(PullResponse { | |
| 408 | + | changes, | |
| 409 | + | cursor: new_cursor, | |
| 410 | + | has_more, | |
| 411 | + | })) | |
| 412 | + | } |
| @@ -18,6 +18,7 @@ pub(crate) mod apps; | |||
| 18 | 18 | pub(crate) mod auth; | |
| 19 | 19 | pub(crate) mod billing; | |
| 20 | 20 | pub(crate) mod blobs; | |
| 21 | + | pub(crate) mod groups; | |
| 21 | 22 | pub(crate) mod keys; | |
| 22 | 23 | mod subscribe; | |
| 23 | 24 | pub(crate) mod sync; | |
| @@ -33,7 +34,7 @@ use crate::{ | |||
| 33 | 34 | CsrfRouter, delete_csrf, delete_csrf_skip, patch_csrf, post_csrf, post_csrf_skip, put_csrf, | |
| 34 | 35 | put_csrf_skip, | |
| 35 | 36 | }, | |
| 36 | - | db::{self, SyncAppId, SyncDeviceId, SyncOperation, SyncPlatform, UserId}, | |
| 37 | + | db::{self, SyncAppId, SyncDeviceId, SyncGroupId, SyncOperation, SyncPlatform, UserId}, | |
| 37 | 38 | }; | |
| 38 | 39 | ||
| 39 | 40 | /// Reason strings for synckit CSRF Skip routes. The auth_routes and | |
| @@ -191,6 +192,73 @@ pub(crate) struct SyncAccountResponse { | |||
| 191 | 192 | pub username: String, | |
| 192 | 193 | } | |
| 193 | 194 | ||
| 195 | + | // ── Group types ── | |
| 196 | + | ||
| 197 | + | #[derive(Deserialize, utoipa::ToSchema)] | |
| 198 | + | pub(crate) struct CreateGroupRequest { | |
| 199 | + | pub name: String, | |
| 200 | + | /// The Group Content Key sealed to the creating admin's own identity public | |
| 201 | + | /// key (base64), opaque to the server. | |
| 202 | + | pub admin_sealed_gck: String, | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 206 | + | pub(crate) struct GroupResponse { | |
| 207 | + | #[schema(value_type = String)] | |
| 208 | + | id: SyncGroupId, | |
| 209 | + | #[schema(value_type = String)] | |
| 210 | + | app_id: SyncAppId, | |
| 211 | + | #[schema(value_type = String)] | |
| 212 | + | admin_user_id: UserId, | |
| 213 | + | name: String, | |
| 214 | + | gck_version: i32, | |
| 215 | + | #[schema(value_type = String)] | |
| 216 | + | created_at: DateTime<Utc>, | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | impl From<db::DbSyncGroup> for GroupResponse { | |
| 220 | + | fn from(g: db::DbSyncGroup) -> Self { | |
| 221 | + | Self { | |
| 222 | + | id: g.id, | |
| 223 | + | app_id: g.app_id, | |
| 224 | + | admin_user_id: g.admin_user_id, | |
| 225 | + | name: g.name, | |
| 226 | + | gck_version: g.gck_version, | |
| 227 | + | created_at: g.created_at, | |
| 228 | + | } | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[derive(Deserialize, utoipa::ToSchema)] | |
| 233 | + | pub(crate) struct AddMemberRequest { | |
| 234 | + | /// The member's account email, resolved to a verified user server-side. | |
| 235 | + | pub member_email: String, | |
| 236 | + | /// The GCK sealed to the member's identity public key (base64), produced by | |
| 237 | + | /// the admin with the group's current GCK. Opaque to the server. | |
| 238 | + | pub sealed_gck: String, | |
| 239 | + | /// Optional role: "member" (default) or "admin". | |
| 240 | + | #[serde(default)] | |
| 241 | + | pub role: Option<String>, | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 245 | + | pub(crate) struct GroupMemberResponse { | |
| 246 | + | #[schema(value_type = String)] | |
| 247 | + | user_id: UserId, | |
| 248 | + | role: String, | |
| 249 | + | #[schema(value_type = String)] | |
| 250 | + | added_at: DateTime<Utc>, | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 254 | + | pub(crate) struct GroupGrantResponse { | |
| 255 | + | /// The caller's sealed GCK grant (base64); opened client-side with the | |
| 256 | + | /// member's identity private key. | |
| 257 | + | sealed_gck: String, | |
| 258 | + | /// The GCK generation this grant was sealed under. | |
| 259 | + | gck_version: i32, | |
| 260 | + | } | |
| 261 | + | ||
| 194 | 262 | /// Status of the authenticated user's subscription to this app's cloud sync. | |
| 195 | 263 | /// Shape matches `synckit_client::SubscriptionStatus`. | |
| 196 | 264 | #[derive(Serialize, utoipa::ToSchema)] | |
| @@ -697,6 +765,58 @@ pub fn synckit_routes(synckit_jwt_secret: Option<std::sync::Arc<String>>) -> Csr | |||
| 697 | 765 | "/api/v1/sync/pull", | |
| 698 | 766 | post_csrf_skip(SYNCKIT_JWT_SKIP, sync::sync_pull), | |
| 699 | 767 | ) | |
| 768 | + | // Group sync: shared changelogs. Membership/admin gating lives inside the | |
| 769 | + | // handlers (SyncUser identifies the caller); same JWT auth + dual rate | |
| 770 | + | // limit as personal sync. GET+POST on one path merge, as with devices. | |
| 771 | + | .route( | |
| 772 | + | "/api/sync/groups", | |
| 773 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group), | |
| 774 | + | ) | |
| 775 | + | .route( | |
| 776 | + | "/api/v1/sync/groups", | |
| 777 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::create_group), | |
| 778 | + | ) | |
| 779 | + | .route_get("/api/sync/groups", get(groups::list_groups)) | |
| 780 | + | .route_get("/api/v1/sync/groups", get(groups::list_groups)) | |
| 781 | + | .route( | |
| 782 | + | "/api/sync/groups/{id}/members", | |
| 783 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member), | |
| 784 | + | ) | |
| 785 | + | .route( | |
| 786 | + | "/api/v1/sync/groups/{id}/members", | |
| 787 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::add_member), | |
| 788 | + | ) | |
| 789 | + | .route_get("/api/sync/groups/{id}/members", get(groups::list_members)) | |
| 790 | + | .route_get( | |
| 791 | + | "/api/v1/sync/groups/{id}/members", | |
| 792 | + | get(groups::list_members), | |
| 793 | + | ) | |
| 794 | + | .route( | |
| 795 | + | "/api/sync/groups/{id}/members/{user_id}", | |
| 796 | + | delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member), | |
| 797 | + | ) | |
| 798 | + | .route( | |
| 799 | + | "/api/v1/sync/groups/{id}/members/{user_id}", | |
| 800 | + | delete_csrf_skip(SYNCKIT_JWT_SKIP, groups::remove_member), | |
| 801 | + | ) | |
| 802 | + | .route_get("/api/sync/groups/{id}/grant", get(groups::get_grant)) | |
| 803 | + | .route_get("/api/v1/sync/groups/{id}/grant", get(groups::get_grant)) | |
| 804 | + | .route( | |
| 805 | + | "/api/sync/groups/{id}/push", | |
| 806 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push), | |
| 807 | + | ) | |
| 808 | + | .route( | |
| 809 | + | "/api/v1/sync/groups/{id}/push", | |
| 810 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push), | |
| 811 | + | ) | |
| 812 | + | .route( | |
| 813 | + | "/api/sync/groups/{id}/pull", | |
| 814 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull), | |
| 815 | + | ) | |
| 816 | + | .route( | |
| 817 | + | "/api/v1/sync/groups/{id}/pull", | |
| 818 | + | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_pull), | |
| 819 | + | ) | |
| 700 | 820 | .route_get("/api/sync/subscribe", get(subscribe::sync_subscribe)) | |
| 701 | 821 | .route_get("/api/v1/sync/subscribe", get(subscribe::sync_subscribe)) | |
| 702 | 822 | .route_get("/api/sync/status", get(sync::sync_status)) |
| @@ -195,6 +195,26 @@ pub fn validate_sync_device_name(name: &str) -> Result<(), AppError> { | |||
| 195 | 195 | Ok(()) | |
| 196 | 196 | } | |
| 197 | 197 | ||
| 198 | + | /// Validate a sync group name. Same shape as an app/device name: non-empty, | |
| 199 | + | /// bounded by the `VARCHAR(100)` column, no control characters. | |
| 200 | + | pub fn validate_sync_group_name(name: &str) -> Result<(), AppError> { | |
| 201 | + | if name.is_empty() { | |
| 202 | + | return Err(AppError::validation("Group name is required".to_string())); | |
| 203 | + | } | |
| 204 | + | if name.chars().count() > limits::SYNC_APP_NAME_MAX { | |
| 205 | + | return Err(AppError::validation(format!( | |
| 206 | + | "Group name must be {} characters or less", | |
| 207 | + | limits::SYNC_APP_NAME_MAX | |
| 208 | + | ))); | |
| 209 | + | } | |
| 210 | + | if name.chars().any(|c| c.is_control()) { | |
| 211 | + | return Err(AppError::validation( | |
| 212 | + | "Group name must not contain control characters".to_string(), | |
| 213 | + | )); | |
| 214 | + | } | |
| 215 | + | Ok(()) | |
| 216 | + | } | |
| 217 | + | ||
| 198 | 218 | /// Validate a sync table name | |
| 199 | 219 | pub fn validate_sync_table_name(name: &str) -> Result<(), AppError> { | |
| 200 | 220 | if name.is_empty() { |
| @@ -0,0 +1,480 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::groups` and the group-scoped | |
| 2 | + | //! changelog (`push_group_changes` / `pull_group_changes_filtered`). | |
| 3 | + | //! | |
| 4 | + | //! The load-bearing test here is `group_entries_never_appear_in_personal_sync`: | |
| 5 | + | //! group changes live in their own `sync_group_log` table, so personal-scope | |
| 6 | + | //! queries (personal pull, status) can never see GCK-encrypted group rows. That | |
| 7 | + | //! isolation is structural, not a `group_id IS NULL` guard sprinkled across every | |
| 8 | + | //! personal query. Design: wiki synckit-groups-design. | |
| 9 | + | ||
| 10 | + | use crate::harness::db::TestDb; | |
| 11 | + | use makenotwork::db::synckit; | |
| 12 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 13 | + | use uuid::Uuid; | |
| 14 | + | ||
| 15 | + | /// Seed a verified user. | |
| 16 | + | async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId { | |
| 17 | + | let hash = makenotwork::auth::hash_password("password123").expect("hash"); | |
| 18 | + | sqlx::query_scalar::<_, UserId>( | |
| 19 | + | "INSERT INTO users (username, email, password_hash, email_verified) | |
| 20 | + | VALUES ($1, $2, $3, true) RETURNING id", | |
| 21 | + | ) | |
| 22 | + | .bind(username) | |
| 23 | + | .bind(format!("{username}@test.com")) | |
| 24 | + | .bind(&hash) | |
| 25 | + | .fetch_one(pool) | |
| 26 | + | .await | |
| 27 | + | .expect("seed user") | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Seed a sync app owned by `user`. | |
| 31 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 32 | + | sqlx::query_scalar::<_, SyncAppId>( | |
| 33 | + | "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) | |
| 34 | + | VALUES ($1, $2, $3, $4) RETURNING id", | |
| 35 | + | ) | |
| 36 | + | .bind(user) | |
| 37 | + | .bind(name) | |
| 38 | + | .bind(format!("hash_{name}")) | |
| 39 | + | .bind(&name[..name.len().min(8)]) | |
| 40 | + | .fetch_one(pool) | |
| 41 | + | .await | |
| 42 | + | .expect("seed sync app") | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | /// Seed a device row for a user within an app. | |
| 46 | + | async fn seed_device( | |
| 47 | + | pool: &sqlx::PgPool, | |
| 48 | + | app: SyncAppId, | |
| 49 | + | user: UserId, | |
| 50 | + | name: &str, | |
| 51 | + | ) -> SyncDeviceId { | |
| 52 | + | sqlx::query_scalar::<_, SyncDeviceId>( | |
| 53 | + | "INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 54 | + | VALUES ($1, $2, $3, 'macos') RETURNING id", | |
| 55 | + | ) | |
| 56 | + | .bind(app) | |
| 57 | + | .bind(user) | |
| 58 | + | .bind(name) | |
| 59 | + | .fetch_one(pool) | |
| 60 | + | .await | |
| 61 | + | .expect("seed device") | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// One INSERT change tuple in the shape `push_*_changes` expects. | |
| 65 | + | fn change( | |
| 66 | + | table: &str, | |
| 67 | + | row: &str, | |
| 68 | + | v: i32, | |
| 69 | + | ) -> ( | |
| 70 | + | String, | |
| 71 | + | String, | |
| 72 | + | String, | |
| 73 | + | chrono::DateTime<chrono::Utc>, | |
| 74 | + | Option<serde_json::Value>, | |
| 75 | + | ) { | |
| 76 | + | ( | |
| 77 | + | table.to_string(), | |
| 78 | + | "INSERT".to_string(), | |
| 79 | + | row.to_string(), | |
| 80 | + | chrono::Utc::now(), | |
| 81 | + | Some(serde_json::json!({ "v": v })), | |
| 82 | + | ) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // ── Group + membership + grants ────────────────────────────────────────────── | |
| 86 | + | ||
| 87 | + | #[tokio::test] | |
| 88 | + | async fn create_group_enrolls_admin_as_member_and_admin() { | |
| 89 | + | let db = TestDb::new().await; | |
| 90 | + | let admin = seed_user(&db.pool, "grp_admin").await; | |
| 91 | + | let app = seed_app(&db.pool, admin, "grpcreate").await; | |
| 92 | + | ||
| 93 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin") | |
| 94 | + | .await | |
| 95 | + | .unwrap(); | |
| 96 | + | ||
| 97 | + | assert_eq!(group.gck_version, 1); | |
| 98 | + | assert!( | |
| 99 | + | synckit::is_group_member(&db.pool, group.id, admin) | |
| 100 | + | .await | |
| 101 | + | .unwrap() | |
| 102 | + | ); | |
| 103 | + | assert!( | |
| 104 | + | synckit::is_group_admin(&db.pool, group.id, admin) | |
| 105 | + | .await | |
| 106 | + | .unwrap() | |
| 107 | + | ); | |
| 108 | + | assert_eq!( | |
| 109 | + | synckit::get_member_grant(&db.pool, group.id, admin) | |
| 110 | + | .await | |
| 111 | + | .unwrap(), | |
| 112 | + | Some(("sealed_admin".to_string(), 1)), | |
| 113 | + | ); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | #[tokio::test] | |
| 117 | + | async fn add_member_stores_grant_and_gates_admin() { | |
| 118 | + | let db = TestDb::new().await; | |
| 119 | + | let admin = seed_user(&db.pool, "gm_admin").await; | |
| 120 | + | let app = seed_app(&db.pool, admin, "gmadd").await; | |
| 121 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin") | |
| 122 | + | .await | |
| 123 | + | .unwrap(); | |
| 124 | + | ||
| 125 | + | let bob = seed_user(&db.pool, "gm_bob").await; | |
| 126 | + | synckit::add_or_update_member( | |
| 127 | + | &db.pool, | |
| 128 | + | group.id, | |
| 129 | + | bob, | |
| 130 | + | "member", | |
| 131 | + | "sealed_bob", | |
| 132 | + | group.gck_version, | |
| 133 | + | ) | |
| 134 | + | .await | |
| 135 | + | .unwrap(); | |
| 136 | + | ||
| 137 | + | assert!( | |
| 138 | + | synckit::is_group_member(&db.pool, group.id, bob) | |
| 139 | + | .await | |
| 140 | + | .unwrap() | |
| 141 | + | ); | |
| 142 | + | assert!( | |
| 143 | + | !synckit::is_group_admin(&db.pool, group.id, bob) | |
| 144 | + | .await | |
| 145 | + | .unwrap() | |
| 146 | + | ); | |
| 147 | + | assert_eq!( | |
| 148 | + | synckit::get_member_grant(&db.pool, group.id, bob) | |
| 149 | + | .await | |
| 150 | + | .unwrap(), | |
| 151 | + | Some(("sealed_bob".to_string(), 1)), | |
| 152 | + | ); | |
| 153 | + | ||
| 154 | + | // A non-member has no grant and is not a member. | |
| 155 | + | let carol = seed_user(&db.pool, "gm_carol").await; | |
| 156 | + | assert!( | |
| 157 | + | !synckit::is_group_member(&db.pool, group.id, carol) | |
| 158 | + | .await | |
| 159 | + | .unwrap() | |
| 160 | + | ); | |
| 161 | + | assert_eq!( | |
| 162 | + | synckit::get_member_grant(&db.pool, group.id, carol) | |
| 163 | + | .await | |
| 164 | + | .unwrap(), | |
| 165 | + | None | |
| 166 | + | ); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[tokio::test] | |
| 170 | + | async fn add_or_update_member_is_idempotent_upsert() { | |
| 171 | + | let db = TestDb::new().await; | |
| 172 | + | let admin = seed_user(&db.pool, "up_admin").await; | |
| 173 | + | let app = seed_app(&db.pool, admin, "upsert").await; | |
| 174 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin") | |
| 175 | + | .await | |
| 176 | + | .unwrap(); | |
| 177 | + | let bob = seed_user(&db.pool, "up_bob").await; | |
| 178 | + | ||
| 179 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1) | |
| 180 | + | .await | |
| 181 | + | .unwrap(); | |
| 182 | + | // Re-adding replaces the grant in place, not a second row. | |
| 183 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v2", 1) | |
| 184 | + | .await | |
| 185 | + | .unwrap(); | |
| 186 | + | ||
| 187 | + | assert_eq!( | |
| 188 | + | synckit::get_member_grant(&db.pool, group.id, bob) | |
| 189 | + | .await | |
| 190 | + | .unwrap(), | |
| 191 | + | Some(("grant_v2".to_string(), 1)), | |
| 192 | + | ); | |
| 193 | + | // admin + bob, no duplicate. | |
| 194 | + | assert_eq!( | |
| 195 | + | synckit::list_members(&db.pool, group.id) | |
| 196 | + | .await | |
| 197 | + | .unwrap() | |
| 198 | + | .len(), | |
| 199 | + | 2 | |
| 200 | + | ); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | #[tokio::test] | |
| 204 | + | async fn list_groups_for_user_is_membership_scoped() { | |
| 205 | + | let db = TestDb::new().await; | |
| 206 | + | let admin = seed_user(&db.pool, "lg_admin").await; | |
| 207 | + | let app = seed_app(&db.pool, admin, "listgrp").await; | |
| 208 | + | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s") | |
| 209 | + | .await | |
| 210 | + | .unwrap(); | |
| 211 | + | let _g2 = synckit::create_group(&db.pool, app, admin, "Two", "s") | |
| 212 | + | .await | |
| 213 | + | .unwrap(); | |
| 214 | + | ||
| 215 | + | let bob = seed_user(&db.pool, "lg_bob").await; | |
| 216 | + | synckit::add_or_update_member(&db.pool, g1.id, bob, "member", "sb", 1) | |
| 217 | + | .await | |
| 218 | + | .unwrap(); | |
| 219 | + | let carol = seed_user(&db.pool, "lg_carol").await; | |
| 220 | + | ||
| 221 | + | assert_eq!( | |
| 222 | + | synckit::list_groups_for_user(&db.pool, app, admin) | |
| 223 | + | .await | |
| 224 | + | .unwrap() | |
| 225 | + | .len(), | |
| 226 | + | 2 | |
| 227 | + | ); | |
| 228 | + | assert_eq!( | |
| 229 | + | synckit::list_groups_for_user(&db.pool, app, bob) | |
| 230 | + | .await | |
| 231 | + | .unwrap() | |
| 232 | + | .len(), | |
| 233 | + | 1 | |
| 234 | + | ); | |
| 235 | + | assert_eq!( | |
| 236 | + | synckit::list_groups_for_user(&db.pool, app, carol) | |
| 237 | + | .await | |
| 238 | + | .unwrap() | |
| 239 | + | .len(), | |
| 240 | + | 0 | |
| 241 | + | ); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[tokio::test] | |
| 245 | + | async fn remove_member_revokes_membership() { | |
| 246 | + | let db = TestDb::new().await; | |
| 247 | + | let admin = seed_user(&db.pool, "rm_admin").await; | |
| 248 | + | let app = seed_app(&db.pool, admin, "removem").await; | |
| 249 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "s") | |
| 250 | + | .await | |
| 251 | + | .unwrap(); | |
| 252 | + | let bob = seed_user(&db.pool, "rm_bob").await; | |
| 253 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "sb", 1) | |
| 254 | + | .await | |
| 255 | + | .unwrap(); | |
| 256 | + | ||
| 257 | + | assert!( | |
| 258 | + | synckit::remove_member(&db.pool, group.id, bob) | |
| 259 | + | .await | |
| 260 | + | .unwrap() | |
| 261 | + | ); | |
| 262 | + | assert!( | |
| 263 | + | !synckit::is_group_member(&db.pool, group.id, bob) | |
| 264 | + | .await | |
| 265 | + | .unwrap() | |
| 266 | + | ); | |
| 267 | + | // Removing again is a no-op. | |
| 268 | + | assert!( | |
| 269 | + | !synckit::remove_member(&db.pool, group.id, bob) | |
| 270 | + | .await | |
| 271 | + | .unwrap() | |
| 272 | + | ); | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // ── Group changelog push/pull ──────────────────────────────────────────────── | |
| 276 | + | ||
| 277 | + | #[tokio::test] | |
| 278 | + | async fn group_push_pull_roundtrip_and_idempotent() { | |
| 279 | + | let db = TestDb::new().await; | |
| 280 | + | let admin = seed_user(&db.pool, "pp_admin").await; | |
| 281 | + | let app = seed_app(&db.pool, admin, "pushpull").await; | |
| 282 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "s") | |
| 283 | + | .await | |
| 284 | + | .unwrap(); | |
| 285 | + | let device = seed_device(&db.pool, app, admin, "dev").await; | |
| 286 | + | ||
| 287 | + | let batch = Uuid::new_v4(); | |
| 288 | + | let changes = vec![change("tasks", "r1", 1), change("tasks", "r2", 2)]; | |
| 289 | + | let cursor = | |
| 290 | + | synckit::push_group_changes(&db.pool, app, group.id, admin, device, batch, &changes) | |
| 291 | + | .await | |
| 292 | + | .unwrap(); | |
| 293 | + | assert!(cursor > 0); | |
| 294 | + | ||
| 295 | + | let pulled = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None) | |
| 296 | + | .await | |
| 297 | + | .unwrap(); | |
| 298 | + | assert_eq!(pulled.len(), 2); | |
| 299 | + | assert!(pulled.iter().all(|e| e.group_id == group.id)); | |
| 300 | + | ||
| 301 | + | // Re-pushing the same batch returns the same cursor and inserts nothing new. | |
| 302 | + | let cursor2 = | |
| 303 | + | synckit::push_group_changes(&db.pool, app, group.id, admin, device, batch, &changes) | |
| 304 | + | .await | |
| 305 | + | .unwrap(); | |
| 306 | + | assert_eq!(cursor2, cursor); | |
| 307 | + | let pulled2 = synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None) | |
| 308 | + | .await | |
| 309 | + | .unwrap(); | |
| 310 | + | assert_eq!(pulled2.len(), 2); | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | #[tokio::test] | |
| 314 | + | async fn group_pull_is_scoped_to_its_group() { | |
| 315 | + | let db = TestDb::new().await; | |
| 316 | + | let admin = seed_user(&db.pool, "sc_admin").await; | |
| 317 | + | let app = seed_app(&db.pool, admin, "scoped").await; | |
| 318 | + | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s") | |
| 319 | + | .await | |
| 320 | + | .unwrap(); | |
| 321 | + | let g2 = synckit::create_group(&db.pool, app, admin, "Two", "s") | |
| 322 | + | .await | |
| 323 | + | .unwrap(); | |
| 324 | + | let device = seed_device(&db.pool, app, admin, "dev").await; | |
| 325 | + | ||
| 326 | + | synckit::push_group_changes( | |
| 327 | + | &db.pool, | |
| 328 | + | app, | |
| 329 | + | g1.id, | |
| 330 | + | admin, | |
| 331 | + | device, | |
| 332 | + | Uuid::new_v4(), | |
| 333 | + | &[change("t", "a", 1)], | |
| 334 | + | ) | |
| 335 | + | .await | |
| 336 | + | .unwrap(); | |
| 337 | + | synckit::push_group_changes( | |
| 338 | + | &db.pool, | |
| 339 | + | app, | |
| 340 | + | g2.id, | |
| 341 | + | admin, | |
| 342 | + | device, | |
| 343 | + | Uuid::new_v4(), | |
| 344 | + | &[change("t", "b", 2)], | |
| 345 | + | ) | |
| 346 | + | .await | |
| 347 | + | .unwrap(); | |
| 348 | + | ||
| 349 | + | // Each group sees only its own entries. | |
| 350 | + | assert_eq!( | |
| 351 | + | synckit::pull_group_changes_filtered(&db.pool, app, g1.id, 0, 100, None, None) | |
| 352 | + | .await | |
| 353 | + | .unwrap() | |
| 354 | + | .len(), | |
| 355 | + | 1 | |
| 356 | + | ); | |
| 357 | + | assert_eq!( | |
| 358 | + | synckit::pull_group_changes_filtered(&db.pool, app, g2.id, 0, 100, None, None) | |
| 359 | + | .await | |
| 360 | + | .unwrap() | |
| 361 | + | .len(), | |
| 362 | + | 1 | |
| 363 | + | ); | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | /// The payoff of the dedicated `sync_group_log` table: group changes are | |
| 367 | + | /// invisible to personal-scope queries, and vice versa, with no per-query guard. | |
| 368 | + | #[tokio::test] | |
| 369 | + | async fn group_entries_never_appear_in_personal_sync() { | |
| 370 | + | let db = TestDb::new().await; | |
| 371 | + | let user = seed_user(&db.pool, "iso_user").await; | |
| 372 | + | let app = seed_app(&db.pool, user, "isolate").await; | |
| 373 | + | let group = synckit::create_group(&db.pool, app, user, "Team", "s") | |
| 374 | + | .await | |
| 375 | + | .unwrap(); | |
| 376 | + | let device = seed_device(&db.pool, app, user, "dev").await; | |
| 377 | + | ||
| 378 | + | // Push two GROUP changes as this user's device. | |
| 379 | + | synckit::push_group_changes( | |
| 380 | + | &db.pool, | |
| 381 | + | app, | |
| 382 | + | group.id, | |
| 383 | + | user, | |
| 384 | + | device, | |
| 385 | + | Uuid::new_v4(), | |
| 386 | + | &[change("tasks", "g1", 1), change("tasks", "g2", 2)], | |
| 387 | + | ) | |
| 388 | + | .await | |
| 389 | + | .unwrap(); | |
| 390 | + | ||
| 391 | + | // The user's PERSONAL pull sees nothing — group rows are in a different table. | |
| 392 | + | let personal = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 393 | + | .await | |
| 394 | + | .unwrap(); | |
| 395 | + | assert!( | |
| 396 | + | personal.is_empty(), | |
| 397 | + | "group entries must not leak into personal pull" | |
| 398 | + | ); | |
| 399 | + | ||
| 400 | + | // Personal status counts zero personal changes. | |
| 401 | + | let (total, latest) = synckit::get_sync_status(&db.pool, app, user).await.unwrap(); | |
| 402 | + | assert_eq!(total, 0); | |
| 403 | + | assert_eq!(latest, None); | |
| 404 | + | ||
| 405 | + | // A PERSONAL push does not appear in the group pull either. | |
| 406 | + | synckit::push_sync_changes( | |
| 407 | + | &db.pool, | |
| 408 | + | app, | |
| 409 | + | user, | |
| 410 | + | device, | |
| 411 | + | Uuid::new_v4(), | |
| 412 | + | &[change("notes", "p1", 9)], | |
| 413 | + | ) | |
| 414 | + | .await | |
| 415 | + | .unwrap(); | |
| 416 | + | let group_pull = | |
| 417 | + | synckit::pull_group_changes_filtered(&db.pool, app, group.id, 0, 100, None, None) | |
| 418 | + | .await | |
| 419 | + | .unwrap(); | |
| 420 | + | assert_eq!( | |
| 421 | + | group_pull.len(), | |
| 422 | + | 2, | |
| 423 | + | "personal entries must not leak into group pull" | |
| 424 | + | ); | |
| 425 | + | ||
| 426 | + | // And the personal pull now sees exactly the one personal entry. | |
| 427 | + | let personal2 = synckit::pull_sync_changes_filtered(&db.pool, app, user, 0, 100, None, None) | |
| 428 | + | .await | |
| 429 | + | .unwrap(); | |
| 430 | + | assert_eq!(personal2.len(), 1); | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | // ── Rotation ───────────────────────────────────────────────────────────────── | |
| 434 | + | ||
| 435 | + | #[tokio::test] | |
| 436 | + | async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() { | |
| 437 | + | let db = TestDb::new().await; | |
| 438 | + | let admin = seed_user(&db.pool, "rot_admin").await; | |
| 439 | + | let app = seed_app(&db.pool, admin, "grprot").await; | |
| 440 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "admin_v1") | |
| 441 | + | .await | |
| 442 | + | .unwrap(); | |
| 443 | + | let bob = seed_user(&db.pool, "rot_bob").await; | |
| 444 | + | let carol = seed_user(&db.pool, "rot_carol").await; | |
| 445 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "bob_v1", 1) | |
| 446 | + | .await | |
| 447 | + | .unwrap(); | |
| 448 | + | synckit::add_or_update_member(&db.pool, group.id, carol, "member", "carol_v1", 1) | |
| 449 | + | .await | |
| 450 | + | .unwrap(); | |
| 451 | + | ||
| 452 | + | // Rotate to generation 2, re-granting admin + bob only (carol is removed). | |
| 453 | + | let grants = vec![(admin, "admin_v2".to_string()), (bob, "bob_v2".to_string())]; | |
| 454 | + | synckit::rotate_group_gck(&db.pool, group.id, 2, &grants) | |
| 455 | + | .await | |
| 456 | + | .unwrap(); | |
| 457 | + | ||
| 458 | + | let g = synckit::get_group(&db.pool, app, group.id) | |
| 459 | + | .await | |
| 460 | + | .unwrap() | |
| 461 | + | .unwrap(); | |
| 462 | + | assert_eq!(g.gck_version, 2); | |
| 463 | + | assert_eq!( | |
| 464 | + | synckit::get_member_grant(&db.pool, group.id, admin) | |
| 465 | + | .await | |
| 466 | + | .unwrap(), | |
| 467 | + | Some(("admin_v2".to_string(), 2)) | |
| 468 | + | ); | |
| 469 | + | assert_eq!( | |
| 470 | + | synckit::get_member_grant(&db.pool, group.id, bob) | |
| 471 | + | .await | |
| 472 | + | .unwrap(), | |
| 473 | + | Some(("bob_v2".to_string(), 2)) | |
| 474 | + | ); | |
| 475 | + | assert!( | |
| 476 | + | !synckit::is_group_member(&db.pool, group.id, carol) | |
| 477 | + | .await | |
| 478 | + | .unwrap() | |
| 479 | + | ); | |
| 480 | + | } |
| @@ -40,6 +40,7 @@ mod db_scan_jobs_layer; | |||
| 40 | 40 | mod db_scanning_layer; | |
| 41 | 41 | mod db_ssh_keys_layer; | |
| 42 | 42 | mod db_synckit_billing_layer; | |
| 43 | + | mod db_synckit_groups; | |
| 43 | 44 | mod db_synckit_rotation; | |
| 44 | 45 | mod db_transactions_layer; | |
| 45 | 46 | mod db_users_layer; |