Skip to main content

max / synckit

9.0 KB · 261 lines History Blame Raw
1 //! Group membership: the URLs the lifecycle walks and the grants it seals.
2
3 // ── The membership lifecycle ──
4 //
5 // Create, add, list, remove. Every call after the first is addressed by a URL
6 // built from a group id the client itself minted, so the path is where a wrong
7 // id shows up. Rotation covers the removal batch in `group_rotation`; what is
8 // here is the surface around it: which endpoint each call reaches, and whether
9 // the grant it seals opens for the member it names.
10 use crate::common::*;
11 use synckit_client::{IdentityKeypair, open_gck_grant};
12 use wiremock::matchers::path;
13
14 const GROUPS: &str = "/api/v1/sync/groups";
15
16 /// Answers a create with the group the client asked for, as the server does:
17 /// the id and name are the caller's, the generation starts at 1.
18 struct EchoCreatedGroup {
19 admin_id: UserId,
20 app_id: AppId,
21 }
22
23 impl wiremock::Respond for EchoCreatedGroup {
24 fn respond(&self, req: &wiremock::Request) -> ResponseTemplate {
25 let body: serde_json::Value = serde_json::from_slice(&req.body).unwrap();
26 ResponseTemplate::new(200).set_body_json(json!({
27 "id": body["id"],
28 "app_id": self.app_id,
29 "admin_user_id": self.admin_id,
30 "name": body["name"],
31 "gck_version": 1,
32 "created_at": "2026-06-07T00:00:00Z",
33 }))
34 }
35 }
36
37 #[tokio::test]
38 async fn the_membership_lifecycle_walks_the_group_urls() {
39 let kit = MockKit::start().await;
40 let (client, master) = kit.keyed();
41 let admin = IdentityKeypair::from_master_key(&master);
42 let (admin_id, app_id) = test_ids();
43
44 // ── create ──
45 kit.post(GROUPS)
46 .responder(EchoCreatedGroup { admin_id, app_id })
47 .await;
48
49 let group = client.create_group("Design team").await.unwrap();
50 assert_eq!(group.name, "Design team");
51 assert_eq!(group.gck_version, 1);
52
53 let created = kit.bodies("POST", GROUPS).await.remove(0);
54 assert_eq!(created["name"], "Design team");
55 assert_eq!(
56 created["admin_pubkey"].as_str().unwrap(),
57 client.my_identity_public_key().unwrap()
58 );
59 // The admin's grant is sealed before the round-trip, against the id the
60 // client chose, so it must open under generation 1 for that id.
61 let gck = open_gck_grant(
62 created["admin_sealed_gck"].as_str().unwrap(),
63 &admin,
64 &group.id.to_string(),
65 1,
66 )
67 .expect("the creator must be able to open their own grant");
68
69 let members_path = format!("{GROUPS}/{}/members", group.id);
70 let pubkeys_path = format!("{GROUPS}/{}/pubkeys", group.id);
71 let grant_path = format!("{GROUPS}/{}/grant", group.id);
72 let rotate_path = format!("{GROUPS}/{}/rotate", group.id);
73
74 // ── add ──
75 // add_member reads the admin's own grant to recover the key it re-seals, so
76 // hand back exactly what create sealed.
77 kit.get(&grant_path)
78 .json(json!({
79 "sealed_gck": created["admin_sealed_gck"],
80 "gck_version": 1,
81 }))
82 .await;
83 kit.post(&members_path).code(204).empty().await;
84
85 let bob = IdentityKeypair::generate();
86 let bob_id = UserId::new(Uuid::new_v4());
87 client
88 .add_member(group.id, "bob@example.com", &bob.public_key().to_base64())
89 .await
90 .unwrap();
91
92 let added = kit.bodies("POST", &members_path).await.remove(0);
93 assert_eq!(added["member_email"], "bob@example.com");
94 assert_eq!(
95 added["member_pubkey"].as_str().unwrap(),
96 bob.public_key().to_base64()
97 );
98 assert_eq!(
99 open_gck_grant(
100 added["sealed_gck"].as_str().unwrap(),
101 &bob,
102 &group.id.to_string(),
103 1,
104 )
105 .expect("the new member must be able to open the grant sealed to them"),
106 gck,
107 "the member is sealed the group's current key, not a fresh one"
108 );
109
110 // ── list ──
111 kit.get(&members_path)
112 .json(json!([
113 {
114 "user_id": admin_id,
115 "email": "admin@example.com",
116 "role": "admin",
117 "added_at": "2026-06-07T00:00:00Z",
118 },
119 {
120 "user_id": bob_id,
121 "email": "bob@example.com",
122 "role": "member",
123 "added_at": "2026-06-08T00:00:00Z",
124 },
125 ]))
126 .await;
127
128 let members = client.list_members(group.id).await.unwrap();
129 assert_eq!(members.len(), 2);
130 assert_eq!(members[0].user_id, admin_id);
131 assert_eq!(members[0].role, "admin");
132 assert_eq!(members[1].email, "bob@example.com");
133
134 // The pubkey list is a different endpoint from the member list: it carries
135 // the re-seal inputs rather than the roster.
136 kit.get(&pubkeys_path)
137 .json(json!([
138 {"user_id": admin_id, "pubkey": admin.public_key().to_base64()},
139 {"user_id": bob_id, "pubkey": bob.public_key().to_base64()},
140 ]))
141 .await;
142
143 let pubkeys = client.list_member_pubkeys(group.id).await.unwrap();
144 assert_eq!(pubkeys.len(), 2);
145 assert_eq!(pubkeys[1].user_id, bob_id);
146 assert_eq!(pubkeys[1].pubkey, bob.public_key().to_base64());
147
148 // ── remove ──
149 // Removal is a rotation, so it must not touch the per-member URL: leaving
150 // the member's key working is the failure this shape exists to prevent.
151 kit.post(&rotate_path).code(204).empty().await;
152 let member_path = format!("{members_path}/{bob_id}");
153 kit.matching("DELETE", path(member_path.clone()))
154 .code(204)
155 .empty()
156 .await;
157
158 client.remove_member(group.id, bob_id).await.unwrap();
159 assert_eq!(kit.hits(&rotate_path).await, 1);
160 assert_eq!(
161 kit.hits(&member_path).await,
162 0,
163 "remove_member re-keys instead of deleting the membership row"
164 );
165
166 // ── list the groups back ──
167 kit.get(GROUPS)
168 .json(json!([{
169 "id": group.id,
170 "app_id": app_id,
171 "admin_user_id": admin_id,
172 "name": "Design team",
173 "gck_version": 2,
174 "created_at": "2026-06-07T00:00:00Z",
175 }]))
176 .await;
177
178 let groups = client.list_groups().await.unwrap();
179 assert_eq!(groups.len(), 1);
180 assert_eq!(groups[0].id, group.id);
181 assert_eq!(
182 groups[0].gck_version, 2,
183 "the removal advanced the generation"
184 );
185
186 // Each step went to its own URL. The reads a rotation makes are counted
187 // here too: removal re-reads the grant and the pubkey list to build its
188 // re-seal batch, so those are two each rather than one.
189 assert_eq!(kit.hits(&grant_path).await, 2);
190 assert_eq!(kit.hits(&pubkeys_path).await, 2);
191 assert_eq!(kit.hits(&members_path).await, 2, "added, then read back");
192 assert_eq!(kit.hits(GROUPS).await, 2, "created, then listed");
193 }
194
195 #[tokio::test]
196 async fn revoking_without_a_rekey_deletes_the_member_url_and_rotates_nothing() {
197 // The escape hatch for a member whose stored public key will not re-seal:
198 // access ends now, and the group key deliberately does not change.
199 let kit = MockKit::start().await;
200 let client = kit.authed();
201 let group_id = synckit_client::GroupId::new(Uuid::new_v4());
202 let member = UserId::new(Uuid::new_v4());
203
204 let member_path = format!("{GROUPS}/{group_id}/members/{member}");
205 let rotate_path = format!("{GROUPS}/{group_id}/rotate");
206 kit.matching("DELETE", path(member_path.clone()))
207 .code(204)
208 .empty()
209 .await;
210 kit.post(&rotate_path).code(204).empty().await;
211
212 client
213 .revoke_member_without_rekey(group_id, member)
214 .await
215 .unwrap();
216
217 let requests = kit.requests_to(&member_path).await;
218 assert_eq!(requests.len(), 1);
219 assert_eq!(requests[0].method.as_str(), "DELETE");
220 assert_eq!(
221 kit.hits(&rotate_path).await,
222 0,
223 "revocation alone must not mint a new key"
224 );
225 }
226
227 #[tokio::test]
228 async fn a_grant_for_one_generation_asks_for_that_generation() {
229 // Reading an entry written before a rotation needs the grant from the
230 // generation it was sealed under, which is the query the newest-grant read
231 // does not carry.
232 let kit = MockKit::start().await;
233 let (client, master) = kit.keyed();
234 let admin = IdentityKeypair::from_master_key(&master);
235 let group_id = synckit_client::GroupId::new(Uuid::new_v4());
236 let gck = synckit_client::generate_group_key();
237 let sealed =
238 synckit_client::seal_gck_to_member(&gck, &admin.public_key(), &group_id.to_string(), 3)
239 .unwrap();
240
241 let grant_path = format!("{GROUPS}/{group_id}/grant");
242 kit.get(&grant_path)
243 .json(json!({"sealed_gck": sealed, "gck_version": 3}))
244 .await;
245
246 let grant = client.group_grant_at(group_id, 3).await.unwrap();
247 assert_eq!(grant.gck_version, 3);
248 assert_eq!(
249 open_gck_grant(&grant.sealed_gck, &admin, &group_id.to_string(), 3).unwrap(),
250 gck
251 );
252
253 let requests = kit.requests_to(&grant_path).await;
254 assert_eq!(requests.len(), 1);
255 assert_eq!(
256 requests[0].url.query(),
257 Some("version=3"),
258 "the generation travels in the query, not the path"
259 );
260 }
261