//! SyncKit group management and group-scoped push/pull. //! //! A group is a shared, end-to-end-encrypted changelog. The admin mints a Group //! Content Key (GCK) client-side and seals it to each member's identity public //! key; the server stores membership and the opaque sealed grants and never sees //! the GCK or any plaintext. Group push/pull are gated on membership; management //! actions (add/remove member) are gated on being the group admin. //! //! Deferred to later slices (noted where they'd hook in): the paid-write gate and //! SSE push notifications for groups (p2-billing), member public-key storage and //! GCK rotation (p3). Design: wiki synckit-groups-design. use axum::{ Json, extract::{Path, State}, http::StatusCode, response::{IntoResponse, Response}, }; use serde_json::json; use sqlx::PgPool; use crate::{ constants, db::{self, DbSyncGroup, SyncGroupId, UserId}, error::{AppError, Result}, synckit_auth::SyncUser, validation, }; use super::{ AddMemberRequest, CreateGroupRequest, GroupGrantResponse, GroupMemberPubkey, GroupMemberResponse, GroupResponse, PullChangeEntry, PullRequest, PullResponse, PushRequest, PushResponse, }; /// Fetch a group scoped to the caller's app, or 404. Guards every group handler /// against cross-app id guessing before any membership check. async fn require_group( db: &PgPool, app_id: db::SyncAppId, group_id: SyncGroupId, ) -> Result { db::synckit::get_group(db, app_id, group_id) .await? .ok_or(AppError::NotFound) } /// Reject a caller who is not a member of the group (403). The gate for /// group-scoped reads and writes. async fn require_member(db: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<()> { if db::synckit::is_group_member(db, group_id, user_id).await? { Ok(()) } else { Err(AppError::Forbidden) } } /// Reject a caller who is not the group admin (403). The gate for membership /// management. async fn require_admin(db: &PgPool, group_id: SyncGroupId, user_id: UserId) -> Result<()> { if db::synckit::is_group_admin(db, group_id, user_id).await? { Ok(()) } else { Err(AppError::Forbidden) } } /// Create a group. The caller becomes its admin and first member, carrying the /// GCK they sealed to their own identity key. #[utoipa::path(post, path = "/api/v1/sync/groups", tag = "SyncKit", request_body = CreateGroupRequest, responses((status = 200, description = "Created group", body = GroupResponse)), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::create_group")] pub(super) async fn create_group( State(db): State, sync_user: SyncUser, Json(req): Json, ) -> Result { validation::validate_sync_group_name(&req.name)?; if req.admin_sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES || req.admin_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES { return Err(AppError::BadRequest( "Sealed key exceeds size limit".to_string(), )); } let group = db::synckit::create_group( &db, req.id, sync_user.app_id, sync_user.user_id, &req.name, &req.admin_sealed_gck, &req.admin_pubkey, ) .await?; Ok(Json(GroupResponse::from(group))) } /// List the groups the caller belongs to within this app. #[utoipa::path(get, path = "/api/v1/sync/groups", tag = "SyncKit", responses((status = 200, description = "Groups the user belongs to", body = Vec)), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::list_groups")] pub(super) async fn list_groups( State(db): State, sync_user: SyncUser, ) -> Result { let groups = db::synckit::list_groups_for_user(&db, sync_user.app_id, sync_user.user_id).await?; let response: Vec = groups.into_iter().map(GroupResponse::from).collect(); Ok(Json(response)) } /// Add a member to a group (or replace their grant). Admin only. /// /// The admin resolves the member out of band, seals the current GCK to that /// member's public key, and posts `{member_email, sealed_gck}`. The server maps /// the email to a verified account and stores the opaque grant at the group's /// current GCK generation. #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/members", tag = "SyncKit", params(("id" = String, Path, description = "Group ID")), request_body = AddMemberRequest, responses((status = 204, description = "Member added"), (status = 403, description = "Not the group admin")), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::add_group_member")] pub(super) async fn add_member( State(db): State, sync_user: SyncUser, Path(group_id): Path, Json(req): Json, ) -> Result { let group = require_group(&db, sync_user.app_id, group_id).await?; require_admin(&db, group_id, sync_user.user_id).await?; if req.sealed_gck.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES || req.member_pubkey.len() > constants::SYNCKIT_MAX_KEY_ENVELOPE_BYTES { return Err(AppError::BadRequest( "Sealed key exceeds size limit".to_string(), )); } let role = req.role.as_deref().unwrap_or("member"); if role != "member" && role != "admin" { return Err(AppError::BadRequest( "role must be 'member' or 'admin'".to_string(), )); } let email = db::Email::new(&req.member_email) .map_err(|_| AppError::BadRequest("Invalid email address".to_string()))?; let member_id = db::users::get_verified_user_id_by_email(&db, &email) .await? .ok_or_else(|| AppError::BadRequest("No verified account for that email".to_string()))?; // The grant the admin sends is sealed under the group's current GCK, so it is // stored at that generation, along with the member's public key (for re-seal // on a later rotation). db::synckit::add_or_update_member( &db, group_id, member_id, role, &req.sealed_gck, group.gck_version, &req.member_pubkey, ) .await?; Ok(StatusCode::NO_CONTENT) } /// List every member's identity public key. Admin only: the admin re-seals a /// rotated GCK to each of these on member removal. #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/pubkeys", tag = "SyncKit", params(("id" = String, Path, description = "Group ID")), responses((status = 200, description = "Member public keys", body = Vec)), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::list_group_pubkeys")] pub(super) async fn list_pubkeys( State(db): State, sync_user: SyncUser, Path(group_id): Path, ) -> Result { require_group(&db, sync_user.app_id, group_id).await?; require_admin(&db, group_id, sync_user.user_id).await?; let pubkeys = db::synckit::list_member_pubkeys(&db, group_id).await?; let response: Vec = pubkeys .into_iter() .map(|(user_id, pubkey)| GroupMemberPubkey { user_id, pubkey }) .collect(); Ok(Json(response)) } /// Remove a member from a group. Admin only. /// /// This drops the member from future group writes. Forward secrecy for writes /// after removal comes from the admin then rotating the GCK (p3); data the member /// already pulled is already in their hands. #[utoipa::path(delete, path = "/api/v1/sync/groups/{id}/members/{user_id}", tag = "SyncKit", params( ("id" = String, Path, description = "Group ID"), ("user_id" = String, Path, description = "Member user ID"), ), responses((status = 204, description = "Member removed"), (status = 404, description = "Not a member")), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::remove_group_member")] pub(super) async fn remove_member( State(db): State, sync_user: SyncUser, Path((group_id, member_id)): Path<(SyncGroupId, UserId)>, ) -> Result { let group = require_group(&db, sync_user.app_id, group_id).await?; require_admin(&db, group_id, sync_user.user_id).await?; // The admin cannot remove themselves; that would orphan the group. Deleting a // group is a separate action (not yet exposed). if member_id == group.admin_user_id { return Err(AppError::BadRequest( "The group admin cannot be removed".to_string(), )); } if !db::synckit::remove_member(&db, group_id, member_id).await? { return Err(AppError::NotFound); } Ok(StatusCode::NO_CONTENT) } /// List a group's members (id, role, joined-at). Members only. Grants are not /// included; each member fetches only their own via `/grant`. #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/members", tag = "SyncKit", params(("id" = String, Path, description = "Group ID")), responses((status = 200, description = "Group members", body = Vec)), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::list_group_members")] pub(super) async fn list_members( State(db): State, sync_user: SyncUser, Path(group_id): Path, ) -> Result { require_group(&db, sync_user.app_id, group_id).await?; require_member(&db, group_id, sync_user.user_id).await?; let members = db::synckit::list_members(&db, group_id).await?; let response: Vec = members .into_iter() .map(|m| GroupMemberResponse { user_id: m.user_id, email: m.email, role: m.role, added_at: m.added_at, }) .collect(); Ok(Json(response)) } /// Fetch the caller's own sealed GCK grant for a group, so their device can open /// the GCK and read the group changelog. Members only. #[utoipa::path(get, path = "/api/v1/sync/groups/{id}/grant", tag = "SyncKit", params(("id" = String, Path, description = "Group ID")), responses( (status = 200, description = "The caller's sealed grant", body = GroupGrantResponse), (status = 403, description = "Not a member"), ), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::get_group_grant")] pub(super) async fn get_grant( State(db): State, sync_user: SyncUser, Path(group_id): Path, ) -> Result { require_group(&db, sync_user.app_id, group_id).await?; let (sealed_gck, gck_version) = db::synckit::get_member_grant(&db, group_id, sync_user.user_id) .await? .ok_or(AppError::Forbidden)?; Ok(Json(GroupGrantResponse { sealed_gck, gck_version, })) } /// Push encrypted changes to a group's shared changelog. Members only. #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/push", tag = "SyncKit", params(("id" = String, Path, description = "Group ID")), request_body = PushRequest, responses((status = 200, description = "New cursor position", body = PushResponse)), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::group_push", fields(group_id))] pub(super) async fn group_push( State(db): State, sync_user: SyncUser, Path(group_id): Path, Json(req): Json, ) -> Result { let group = require_group(&db, sync_user.app_id, group_id).await?; require_member(&db, group_id, sync_user.user_id).await?; // Group writes bill to the admin's slot (Groups billing decision): the paid // gate is checked against the *admin's* entitlement, not the pushing member's, // so a member with no subscription of their own can still contribute to a // group whose admin pays. Reads (group_pull) stay open, as personal pull does. if !db::synckit::internal_write_allowed(&db, sync_user.app_id, group.admin_user_id).await? { return Ok(( StatusCode::PAYMENT_REQUIRED, Json(json!({ "reason": "no_subscription" })), ) .into_response()); } // NOTE: SSE push notification to group members is deferred to p3; members' // devices pick up group changes on their next scheduler tick meanwhile. if req.changes.is_empty() { return Err(AppError::BadRequest("No changes provided".to_string())); } if req.changes.len() > constants::SYNCKIT_PUSH_MAX_CHANGES { return Err(AppError::BadRequest(format!( "Maximum {} changes per push", constants::SYNCKIT_PUSH_MAX_CHANGES ))); } for change in &req.changes { validation::validate_sync_table_name(&change.table)?; validation::validate_sync_row_id(&change.row_id)?; if change.op == db::SyncOperation::Delete && change.data.is_some() { return Err(AppError::BadRequest( "DELETE operations should not include data".to_string(), )); } } // The pushing device must belong to the pushing user (membership is a // separate, group-level check above). if !db::synckit::sync_device_belongs(&db, req.device_id, sync_user.app_id, sync_user.user_id) .await? { return Err(AppError::BadRequest("Unknown device".to_string())); } db::synckit::touch_sync_device(&db, req.device_id).await?; let changes: Vec<_> = req .changes .iter() .map(|c| { ( c.table.clone(), c.op.to_string(), c.row_id.clone(), c.timestamp, c.data.clone(), ) }) .collect(); let cursor = db::synckit::push_group_changes( &db, sync_user.app_id, group_id, sync_user.user_id, req.device_id, req.batch_id, &changes, ) .await?; Ok(Json(PushResponse { cursor }).into_response()) } /// Pull a group's changes after a cursor. Members only. #[utoipa::path(post, path = "/api/v1/sync/groups/{id}/pull", tag = "SyncKit", params(("id" = String, Path, description = "Group ID")), request_body = PullRequest, responses((status = 200, description = "Changes since cursor", body = PullResponse)), security(("bearer" = [])), )] #[tracing::instrument(skip_all, name = "synckit::group_pull", fields(group_id))] pub(super) async fn group_pull( State(db): State, sync_user: SyncUser, Path(group_id): Path, Json(req): Json, ) -> Result { require_group(&db, sync_user.app_id, group_id).await?; require_member(&db, group_id, sync_user.user_id).await?; if !db::synckit::sync_device_belongs(&db, req.device_id, sync_user.app_id, sync_user.user_id) .await? { return Err(AppError::BadRequest("Unknown device".to_string())); } if let Some(ref tables) = req.tables { if tables.len() > 50 { return Err(AppError::BadRequest( "Maximum 50 table names per filter".to_string(), )); } for table in tables { validation::validate_sync_table_name(table)?; } } let page_size = constants::SYNCKIT_PULL_PAGE_SIZE; let entries = db::synckit::pull_group_changes_filtered( &db, sync_user.app_id, group_id, req.cursor, page_size, req.tables.as_deref(), req.since, ) .await?; let has_more = entries.len() as i64 == page_size; let new_cursor = entries.last().map_or(req.cursor, |e| e.seq); // Touch the device for activity, but do NOT advance the per-device personal // compaction cursor here; that cursor governs personal-changelog retention // and must not be moved by a group pull. Group changelog retention is a // separate concern (future work). db::synckit::touch_sync_device(&db, req.device_id).await?; let changes: Vec = entries .into_iter() .map(|e| PullChangeEntry { seq: e.seq, device_id: e.device_id, table: e.table_name, op: e.operation.to_string(), row_id: e.row_id, timestamp: e.client_timestamp, data: e.data, // Group entries key off the group's GCK, not a per-user key_id. key_id: None, }) .collect(); Ok(Json(PullResponse { changes, cursor: new_cursor, has_more, })) }