|
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 |
+ |
}
|