max / makenotwork
6 files changed,
+148 insertions,
-29 deletions
| @@ -0,0 +1,11 @@ | |||
| 1 | + | -- SyncKit Groups: store each member's identity public key. | |
| 2 | + | -- | |
| 3 | + | -- Needed for non-interactive GCK rotation (p3-rotation): when a member is | |
| 4 | + | -- removed, the admin mints a new GCK and re-seals it to the *remaining* members' | |
| 5 | + | -- public keys without re-collecting them out of band. The key is non-secret | |
| 6 | + | -- metadata (the server already knows group membership); it is never used to | |
| 7 | + | -- decrypt anything server-side. Nullable so a membership row can predate its | |
| 8 | + | -- pubkey; the client always sends it with the grant. Design: wiki | |
| 9 | + | -- synckit-groups-design. | |
| 10 | + | ||
| 11 | + | ALTER TABLE sync_group_members ADD COLUMN IF NOT EXISTS member_pubkey TEXT; |
| @@ -26,6 +26,7 @@ pub async fn create_group( | |||
| 26 | 26 | admin_user_id: UserId, | |
| 27 | 27 | name: &str, | |
| 28 | 28 | admin_sealed_gck: &str, | |
| 29 | + | admin_pubkey: &str, | |
| 29 | 30 | ) -> Result<DbSyncGroup> { | |
| 30 | 31 | let mut tx = pool.begin().await?; | |
| 31 | 32 | ||
| @@ -44,14 +45,15 @@ pub async fn create_group( | |||
| 44 | 45 | ||
| 45 | 46 | sqlx::query( | |
| 46 | 47 | r#" | |
| 47 | - | INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version) | |
| 48 | - | VALUES ($1, $2, 'admin', $3, $4) | |
| 48 | + | INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version, member_pubkey) | |
| 49 | + | VALUES ($1, $2, 'admin', $3, $4, $5) | |
| 49 | 50 | "#, | |
| 50 | 51 | ) | |
| 51 | 52 | .bind(group.id) | |
| 52 | 53 | .bind(admin_user_id) | |
| 53 | 54 | .bind(admin_sealed_gck) | |
| 54 | 55 | .bind(group.gck_version) | |
| 56 | + | .bind(admin_pubkey) | |
| 55 | 57 | .execute(&mut *tx) | |
| 56 | 58 | .await?; | |
| 57 | 59 | ||
| @@ -121,6 +123,26 @@ pub async fn list_members(pool: &PgPool, group_id: SyncGroupId) -> Result<Vec<Db | |||
| 121 | 123 | Ok(members) | |
| 122 | 124 | } | |
| 123 | 125 | ||
| 126 | + | /// The `(user_id, member_pubkey)` of every member with a stored public key. The | |
| 127 | + | /// admin fetches this to re-seal a rotated GCK to the remaining members without | |
| 128 | + | /// re-collecting their keys out of band. Members without a stored key (legacy | |
| 129 | + | /// rows) are omitted. | |
| 130 | + | #[tracing::instrument(skip_all)] | |
| 131 | + | pub async fn list_member_pubkeys( | |
| 132 | + | pool: &PgPool, | |
| 133 | + | group_id: SyncGroupId, | |
| 134 | + | ) -> Result<Vec<(UserId, String)>> { | |
| 135 | + | let rows: Vec<(UserId, String)> = sqlx::query_as( | |
| 136 | + | "SELECT user_id, member_pubkey FROM sync_group_members \ | |
| 137 | + | WHERE group_id = $1 AND member_pubkey IS NOT NULL \ | |
| 138 | + | ORDER BY added_at ASC", | |
| 139 | + | ) | |
| 140 | + | .bind(group_id) | |
| 141 | + | .fetch_all(pool) | |
| 142 | + | .await?; | |
| 143 | + | Ok(rows) | |
| 144 | + | } | |
| 145 | + | ||
| 124 | 146 | /// Whether `user_id` is a member of `group_id`. The membership gate for | |
| 125 | 147 | /// group-scoped push/pull. | |
| 126 | 148 | #[tracing::instrument(skip_all)] | |
| @@ -185,15 +207,17 @@ pub async fn add_or_update_member( | |||
| 185 | 207 | role: &str, | |
| 186 | 208 | sealed_gck: &str, | |
| 187 | 209 | gck_version: i32, | |
| 210 | + | member_pubkey: &str, | |
| 188 | 211 | ) -> Result<()> { | |
| 189 | 212 | sqlx::query( | |
| 190 | 213 | r#" | |
| 191 | - | INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version) | |
| 192 | - | VALUES ($1, $2, $3, $4, $5) | |
| 214 | + | INSERT INTO sync_group_members (group_id, user_id, role, sealed_gck, gck_version, member_pubkey) | |
| 215 | + | VALUES ($1, $2, $3, $4, $5, $6) | |
| 193 | 216 | ON CONFLICT (group_id, user_id) | |
| 194 | 217 | DO UPDATE SET role = EXCLUDED.role, | |
| 195 | 218 | sealed_gck = EXCLUDED.sealed_gck, | |
| 196 | - | gck_version = EXCLUDED.gck_version | |
| 219 | + | gck_version = EXCLUDED.gck_version, | |
| 220 | + | member_pubkey = EXCLUDED.member_pubkey | |
| 197 | 221 | "#, | |
| 198 | 222 | ) | |
| 199 | 223 | .bind(group_id) | |
| @@ -201,6 +225,7 @@ pub async fn add_or_update_member( | |||
| 201 | 225 | .bind(role) | |
| 202 | 226 | .bind(sealed_gck) | |
| 203 | 227 | .bind(gck_version) | |
| 228 | + | .bind(member_pubkey) | |
| 204 | 229 | .execute(pool) | |
| 205 | 230 | .await?; | |
| 206 | 231 | Ok(()) |
| @@ -28,8 +28,9 @@ use crate::{ | |||
| 28 | 28 | }; | |
| 29 | 29 | ||
| 30 | 30 | use super::{ | |
| 31 | - | AddMemberRequest, CreateGroupRequest, GroupGrantResponse, GroupMemberResponse, GroupResponse, | |
| 32 | - | PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse, | |
| 31 | + | AddMemberRequest, CreateGroupRequest, GroupGrantResponse, GroupMemberPubkey, | |
| 32 | + | GroupMemberResponse, GroupResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest, | |
| 33 | + | PushResponse, | |
| 33 | 34 | }; | |
| 34 | 35 | ||
| 35 | 36 | /// Fetch a group scoped to the caller's app, or 404. Guards every group handler | |
| @@ -78,7 +79,9 @@ pub(super) async fn create_group( | |||
| 78 | 79 | Json(req): Json<CreateGroupRequest>, | |
| 79 | 80 | ) -> Result<impl IntoResponse> { | |
| 80 | 81 | validation::validate_sync_group_name(&req.name)?; | |
| 81 | - | if req.admin_sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES { | |
| 82 | + | if req.admin_sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES | |
| 83 | + | || req.admin_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES | |
| 84 | + | { | |
| 82 | 85 | return Err(AppError::BadRequest( | |
| 83 | 86 | "Sealed key exceeds size limit".to_string(), | |
| 84 | 87 | )); | |
| @@ -90,6 +93,7 @@ pub(super) async fn create_group( | |||
| 90 | 93 | sync_user.user_id, | |
| 91 | 94 | &req.name, | |
| 92 | 95 | &req.admin_sealed_gck, | |
| 96 | + | &req.admin_pubkey, | |
| 93 | 97 | ) | |
| 94 | 98 | .await?; | |
| 95 | 99 | ||
| @@ -134,7 +138,9 @@ pub(super) async fn add_member( | |||
| 134 | 138 | let group = require_group(&db, sync_user.app_id, group_id).await?; | |
| 135 | 139 | require_admin(&db, group_id, sync_user.user_id).await?; | |
| 136 | 140 | ||
| 137 | - | if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES { | |
| 141 | + | if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES | |
| 142 | + | || req.member_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES | |
| 143 | + | { | |
| 138 | 144 | return Err(AppError::BadRequest( | |
| 139 | 145 | "Sealed key exceeds size limit".to_string(), | |
| 140 | 146 | )); | |
| @@ -153,7 +159,8 @@ pub(super) async fn add_member( | |||
| 153 | 159 | .ok_or_else(|| AppError::BadRequest("No verified account for that email".to_string()))?; | |
| 154 | 160 | ||
| 155 | 161 | // The grant the admin sends is sealed under the group's current GCK, so it is | |
| 156 | - | // stored at that generation. | |
| 162 | + | // stored at that generation, along with the member's public key (for re-seal | |
| 163 | + | // on a later rotation). | |
| 157 | 164 | db::synckit::add_or_update_member( | |
| 158 | 165 | &db, | |
| 159 | 166 | group_id, | |
| @@ -161,12 +168,37 @@ pub(super) async fn add_member( | |||
| 161 | 168 | role, | |
| 162 | 169 | &req.sealed_gck, | |
| 163 | 170 | group.gck_version, | |
| 171 | + | &req.member_pubkey, | |
| 164 | 172 | ) | |
| 165 | 173 | .await?; | |
| 166 | 174 | ||
| 167 | 175 | Ok(StatusCode::NO_CONTENT) | |
| 168 | 176 | } | |
| 169 | 177 | ||
| 178 | + | /// List every member's identity public key. Admin only — the admin re-seals a | |
| 179 | + | /// rotated GCK to each of these on member removal. | |
| 180 | + | #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/pubkeys", tag = "SyncKit", | |
| 181 | + | params(("id" = String, Path, description = "Group ID")), | |
| 182 | + | responses((status = 200, description = "Member public keys", body = Vec<GroupMemberPubkey>)), | |
| 183 | + | security(("bearer" = [])), | |
| 184 | + | )] | |
| 185 | + | #[tracing::instrument(skip_all, name = "synckit::list_group_pubkeys")] | |
| 186 | + | pub(super) async fn list_pubkeys( | |
| 187 | + | State(db): State<PgPool>, | |
| 188 | + | sync_user: SyncUser, | |
| 189 | + | Path(group_id): Path<SyncGroupId>, | |
| 190 | + | ) -> Result<impl IntoResponse> { | |
| 191 | + | require_group(&db, sync_user.app_id, group_id).await?; | |
| 192 | + | require_admin(&db, group_id, sync_user.user_id).await?; | |
| 193 | + | ||
| 194 | + | let pubkeys = db::synckit::list_member_pubkeys(&db, group_id).await?; | |
| 195 | + | let response: Vec<GroupMemberPubkey> = pubkeys | |
| 196 | + | .into_iter() | |
| 197 | + | .map(|(user_id, pubkey)| GroupMemberPubkey { user_id, pubkey }) | |
| 198 | + | .collect(); | |
| 199 | + | Ok(Json(response)) | |
| 200 | + | } | |
| 201 | + | ||
| 170 | 202 | /// Remove a member from a group. Admin only. | |
| 171 | 203 | /// | |
| 172 | 204 | /// This drops the member from future group writes. Forward secrecy for writes |
| @@ -200,6 +200,9 @@ pub(crate) struct CreateGroupRequest { | |||
| 200 | 200 | /// The Group Content Key sealed to the creating admin's own identity public | |
| 201 | 201 | /// key (base64), opaque to the server. | |
| 202 | 202 | pub admin_sealed_gck: String, | |
| 203 | + | /// The admin's own identity public key (base64), stored so the GCK can be | |
| 204 | + | /// re-sealed to the admin on a later rotation. | |
| 205 | + | pub admin_pubkey: String, | |
| 203 | 206 | } | |
| 204 | 207 | ||
| 205 | 208 | #[derive(Serialize, utoipa::ToSchema)] | |
| @@ -236,11 +239,23 @@ pub(crate) struct AddMemberRequest { | |||
| 236 | 239 | /// The GCK sealed to the member's identity public key (base64), produced by | |
| 237 | 240 | /// the admin with the group's current GCK. Opaque to the server. | |
| 238 | 241 | pub sealed_gck: String, | |
| 242 | + | /// The member's identity public key (base64), stored so the GCK can be | |
| 243 | + | /// re-sealed to them on a later rotation. | |
| 244 | + | pub member_pubkey: String, | |
| 239 | 245 | /// Optional role: "member" (default) or "admin". | |
| 240 | 246 | #[serde(default)] | |
| 241 | 247 | pub role: Option<String>, | |
| 242 | 248 | } | |
| 243 | 249 | ||
| 250 | + | /// One member's identity public key, from `GET /groups/{id}/pubkeys` — the admin | |
| 251 | + | /// re-seals a rotated GCK to each of these. | |
| 252 | + | #[derive(Serialize, utoipa::ToSchema)] | |
| 253 | + | pub(crate) struct GroupMemberPubkey { | |
| 254 | + | #[schema(value_type = String)] | |
| 255 | + | user_id: UserId, | |
| 256 | + | pubkey: String, | |
| 257 | + | } | |
| 258 | + | ||
| 244 | 259 | #[derive(Serialize, utoipa::ToSchema)] | |
| 245 | 260 | pub(crate) struct GroupMemberResponse { | |
| 246 | 261 | #[schema(value_type = String)] | |
| @@ -801,6 +816,11 @@ pub fn synckit_routes(synckit_jwt_secret: Option<std::sync::Arc<String>>) -> Csr | |||
| 801 | 816 | ) | |
| 802 | 817 | .route_get("/api/sync/groups/{id}/grant", get(groups::get_grant)) | |
| 803 | 818 | .route_get("/api/v1/sync/groups/{id}/grant", get(groups::get_grant)) | |
| 819 | + | .route_get("/api/sync/groups/{id}/pubkeys", get(groups::list_pubkeys)) | |
| 820 | + | .route_get( | |
| 821 | + | "/api/v1/sync/groups/{id}/pubkeys", | |
| 822 | + | get(groups::list_pubkeys), | |
| 823 | + | ) | |
| 804 | 824 | .route( | |
| 805 | 825 | "/api/sync/groups/{id}/push", | |
| 806 | 826 | post_csrf_skip(SYNCKIT_JWT_SKIP, groups::group_push), |
| @@ -90,7 +90,7 @@ 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") | |
| 93 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk") | |
| 94 | 94 | .await | |
| 95 | 95 | .unwrap(); | |
| 96 | 96 | ||
| @@ -118,7 +118,7 @@ async fn add_member_stores_grant_and_gates_admin() { | |||
| 118 | 118 | let db = TestDb::new().await; | |
| 119 | 119 | let admin = seed_user(&db.pool, "gm_admin").await; | |
| 120 | 120 | let app = seed_app(&db.pool, admin, "gmadd").await; | |
| 121 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin") | |
| 121 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk") | |
| 122 | 122 | .await | |
| 123 | 123 | .unwrap(); | |
| 124 | 124 | ||
| @@ -130,6 +130,7 @@ async fn add_member_stores_grant_and_gates_admin() { | |||
| 130 | 130 | "member", | |
| 131 | 131 | "sealed_bob", | |
| 132 | 132 | group.gck_version, | |
| 133 | + | "pk_bob", | |
| 133 | 134 | ) | |
| 134 | 135 | .await | |
| 135 | 136 | .unwrap(); | |
| @@ -171,16 +172,16 @@ async fn add_or_update_member_is_idempotent_upsert() { | |||
| 171 | 172 | let db = TestDb::new().await; | |
| 172 | 173 | let admin = seed_user(&db.pool, "up_admin").await; | |
| 173 | 174 | let app = seed_app(&db.pool, admin, "upsert").await; | |
| 174 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin") | |
| 175 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "sealed_admin", "pk") | |
| 175 | 176 | .await | |
| 176 | 177 | .unwrap(); | |
| 177 | 178 | let bob = seed_user(&db.pool, "up_bob").await; | |
| 178 | 179 | ||
| 179 | - | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1) | |
| 180 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v1", 1, "pk") | |
| 180 | 181 | .await | |
| 181 | 182 | .unwrap(); | |
| 182 | 183 | // 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 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "grant_v2", 1, "pk") | |
| 184 | 185 | .await | |
| 185 | 186 | .unwrap(); | |
| 186 | 187 | ||
| @@ -205,15 +206,15 @@ async fn list_groups_for_user_is_membership_scoped() { | |||
| 205 | 206 | let db = TestDb::new().await; | |
| 206 | 207 | let admin = seed_user(&db.pool, "lg_admin").await; | |
| 207 | 208 | let app = seed_app(&db.pool, admin, "listgrp").await; | |
| 208 | - | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s") | |
| 209 | + | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s", "pk") | |
| 209 | 210 | .await | |
| 210 | 211 | .unwrap(); | |
| 211 | - | let _g2 = synckit::create_group(&db.pool, app, admin, "Two", "s") | |
| 212 | + | let _g2 = synckit::create_group(&db.pool, app, admin, "Two", "s", "pk") | |
| 212 | 213 | .await | |
| 213 | 214 | .unwrap(); | |
| 214 | 215 | ||
| 215 | 216 | let bob = seed_user(&db.pool, "lg_bob").await; | |
| 216 | - | synckit::add_or_update_member(&db.pool, g1.id, bob, "member", "sb", 1) | |
| 217 | + | synckit::add_or_update_member(&db.pool, g1.id, bob, "member", "sb", 1, "pk") | |
| 217 | 218 | .await | |
| 218 | 219 | .unwrap(); | |
| 219 | 220 | let carol = seed_user(&db.pool, "lg_carol").await; | |
| @@ -246,11 +247,11 @@ async fn remove_member_revokes_membership() { | |||
| 246 | 247 | let db = TestDb::new().await; | |
| 247 | 248 | let admin = seed_user(&db.pool, "rm_admin").await; | |
| 248 | 249 | let app = seed_app(&db.pool, admin, "removem").await; | |
| 249 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "s") | |
| 250 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "s", "pk") | |
| 250 | 251 | .await | |
| 251 | 252 | .unwrap(); | |
| 252 | 253 | let bob = seed_user(&db.pool, "rm_bob").await; | |
| 253 | - | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "sb", 1) | |
| 254 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "sb", 1, "pk") | |
| 254 | 255 | .await | |
| 255 | 256 | .unwrap(); | |
| 256 | 257 | ||
| @@ -279,7 +280,7 @@ async fn group_push_pull_roundtrip_and_idempotent() { | |||
| 279 | 280 | let db = TestDb::new().await; | |
| 280 | 281 | let admin = seed_user(&db.pool, "pp_admin").await; | |
| 281 | 282 | let app = seed_app(&db.pool, admin, "pushpull").await; | |
| 282 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "s") | |
| 283 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "s", "pk") | |
| 283 | 284 | .await | |
| 284 | 285 | .unwrap(); | |
| 285 | 286 | let device = seed_device(&db.pool, app, admin, "dev").await; | |
| @@ -315,10 +316,10 @@ async fn group_pull_is_scoped_to_its_group() { | |||
| 315 | 316 | let db = TestDb::new().await; | |
| 316 | 317 | let admin = seed_user(&db.pool, "sc_admin").await; | |
| 317 | 318 | let app = seed_app(&db.pool, admin, "scoped").await; | |
| 318 | - | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s") | |
| 319 | + | let g1 = synckit::create_group(&db.pool, app, admin, "One", "s", "pk") | |
| 319 | 320 | .await | |
| 320 | 321 | .unwrap(); | |
| 321 | - | let g2 = synckit::create_group(&db.pool, app, admin, "Two", "s") | |
| 322 | + | let g2 = synckit::create_group(&db.pool, app, admin, "Two", "s", "pk") | |
| 322 | 323 | .await | |
| 323 | 324 | .unwrap(); | |
| 324 | 325 | let device = seed_device(&db.pool, app, admin, "dev").await; | |
| @@ -370,7 +371,7 @@ async fn group_entries_never_appear_in_personal_sync() { | |||
| 370 | 371 | let db = TestDb::new().await; | |
| 371 | 372 | let user = seed_user(&db.pool, "iso_user").await; | |
| 372 | 373 | let app = seed_app(&db.pool, user, "isolate").await; | |
| 373 | - | let group = synckit::create_group(&db.pool, app, user, "Team", "s") | |
| 374 | + | let group = synckit::create_group(&db.pool, app, user, "Team", "s", "pk") | |
| 374 | 375 | .await | |
| 375 | 376 | .unwrap(); | |
| 376 | 377 | let device = seed_device(&db.pool, app, user, "dev").await; | |
| @@ -437,15 +438,15 @@ async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() { | |||
| 437 | 438 | let db = TestDb::new().await; | |
| 438 | 439 | let admin = seed_user(&db.pool, "rot_admin").await; | |
| 439 | 440 | let app = seed_app(&db.pool, admin, "grprot").await; | |
| 440 | - | let group = synckit::create_group(&db.pool, app, admin, "Team", "admin_v1") | |
| 441 | + | let group = synckit::create_group(&db.pool, app, admin, "Team", "admin_v1", "pk") | |
| 441 | 442 | .await | |
| 442 | 443 | .unwrap(); | |
| 443 | 444 | let bob = seed_user(&db.pool, "rot_bob").await; | |
| 444 | 445 | 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 | + | synckit::add_or_update_member(&db.pool, group.id, bob, "member", "bob_v1", 1, "pk") | |
| 446 | 447 | .await | |
| 447 | 448 | .unwrap(); | |
| 448 | - | synckit::add_or_update_member(&db.pool, group.id, carol, "member", "carol_v1", 1) | |
| 449 | + | synckit::add_or_update_member(&db.pool, group.id, carol, "member", "carol_v1", 1, "pk") | |
| 449 | 450 | .await | |
| 450 | 451 | .unwrap(); | |
| 451 | 452 | ||
| @@ -478,3 +479,33 @@ async fn rotate_group_gck_bumps_version_regrants_and_drops_removed() { | |||
| 478 | 479 | .unwrap() | |
| 479 | 480 | ); | |
| 480 | 481 | } | |
| 482 | + | ||
| 483 | + | #[tokio::test] | |
| 484 | + | async fn list_member_pubkeys_returns_admin_and_members() { | |
| 485 | + | let db = TestDb::new().await; | |
| 486 | + | let admin = seed_user(&db.pool, "pk_admin").await; | |
| 487 | + | 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(); | |
| 491 | + | let bob = seed_user(&db.pool, "pk_bob").await; | |
| 492 | + | synckit::add_or_update_member( | |
| 493 | + | &db.pool, | |
| 494 | + | group.id, | |
| 495 | + | bob, | |
| 496 | + | "member", | |
| 497 | + | "sealed_bob", | |
| 498 | + | 1, | |
| 499 | + | "bob_pub", | |
| 500 | + | ) | |
| 501 | + | .await | |
| 502 | + | .unwrap(); | |
| 503 | + | ||
| 504 | + | let mut pubkeys = synckit::list_member_pubkeys(&db.pool, group.id) | |
| 505 | + | .await | |
| 506 | + | .unwrap(); | |
| 507 | + | pubkeys.sort_by_key(|(_, k)| k.clone()); | |
| 508 | + | assert_eq!(pubkeys.len(), 2); | |
| 509 | + | assert_eq!(pubkeys[0], (admin, "admin_pub".to_string())); | |
| 510 | + | assert_eq!(pubkeys[1], (bob, "bob_pub".to_string())); | |
| 511 | + | } |
| @@ -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" }).to_string(), | |
| 22 | + | &json!({ "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); | |
| @@ -115,7 +115,7 @@ async fn member_without_own_subscription_writes_on_admin_slot() { | |||
| 115 | 115 | .client | |
| 116 | 116 | .post_json( | |
| 117 | 117 | &format!("/api/sync/groups/{group_id}/members"), | |
| 118 | - | &json!({ "member_email": "gb2_bob@example.com", "sealed_gck": "sealed_bob" }) | |
| 118 | + | &json!({ "member_email": "gb2_bob@example.com", "sealed_gck": "sealed_bob", "member_pubkey": "pk_bob" }) | |
| 119 | 119 | .to_string(), | |
| 120 | 120 | ) | |
| 121 | 121 | .await; |