1use super::identity::NodeIdentity;
38use super::membership::{
39 AdmissionOutcome, ClusterId, ClusterMember, MemberKind, MembershipCatalog,
40};
41use std::collections::BTreeMap;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct JoinRequest {
50 pub target_cluster: ClusterId,
53 pub identity: NodeIdentity,
55 pub kind: MemberKind,
57}
58
59impl JoinRequest {
60 pub fn authenticated(
63 target_cluster: ClusterId,
64 identity: NodeIdentity,
65 kind: MemberKind,
66 ) -> Self {
67 Self {
68 target_cluster,
69 identity,
70 kind,
71 }
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum JoinRejection {
78 WrongCluster {
80 expected: ClusterId,
81 presented: ClusterId,
82 },
83 UnauthorizedPeer(NodeIdentity),
86 KindMismatch {
88 identity: NodeIdentity,
89 allowed: MemberKind,
90 requested: MemberKind,
91 },
92}
93
94impl std::fmt::Display for JoinRejection {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 match self {
97 Self::WrongCluster {
98 expected,
99 presented,
100 } => write!(
101 f,
102 "join targets cluster {presented}, but this seed serves {expected}"
103 ),
104 Self::UnauthorizedPeer(id) => {
105 write!(f, "peer {id} is not an authorized cluster member")
106 }
107 Self::KindMismatch {
108 identity,
109 allowed,
110 requested,
111 } => write!(
112 f,
113 "peer {identity} is allowed as {allowed:?} but requested {requested:?}"
114 ),
115 }
116 }
117}
118
119impl std::error::Error for JoinRejection {}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
124pub struct ControlPlaneSnapshot {
125 pub cluster_id: ClusterId,
126 pub members: Vec<ClusterMember>,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct JoinGrant {
133 pub outcome: AdmissionOutcome,
134 pub snapshot: ControlPlaneSnapshot,
135}
136
137#[derive(Debug, Clone)]
146pub struct SeedAuthority {
147 allowlist: BTreeMap<NodeIdentity, MemberKind>,
148 catalog: MembershipCatalog,
149}
150
151impl SeedAuthority {
152 pub fn new(
156 catalog: MembershipCatalog,
157 allowlist: impl IntoIterator<Item = (NodeIdentity, MemberKind)>,
158 ) -> Self {
159 let mut allow: BTreeMap<NodeIdentity, MemberKind> = allowlist.into_iter().collect();
160 for member in catalog.members() {
162 allow
163 .entry(member.identity().clone())
164 .or_insert(member.kind());
165 }
166 Self {
167 allowlist: allow,
168 catalog,
169 }
170 }
171
172 pub fn cluster_id(&self) -> &ClusterId {
173 self.catalog.cluster_id()
174 }
175
176 pub fn catalog(&self) -> &MembershipCatalog {
177 &self.catalog
178 }
179
180 pub fn evaluate_join(&mut self, request: JoinRequest) -> Result<JoinGrant, JoinRejection> {
185 if &request.target_cluster != self.catalog.cluster_id() {
187 return Err(JoinRejection::WrongCluster {
188 expected: self.catalog.cluster_id().clone(),
189 presented: request.target_cluster,
190 });
191 }
192
193 let allowed_kind = match self.allowlist.get(&request.identity) {
195 Some(kind) => *kind,
196 None => return Err(JoinRejection::UnauthorizedPeer(request.identity)),
197 };
198
199 if allowed_kind != request.kind {
201 return Err(JoinRejection::KindMismatch {
202 identity: request.identity,
203 allowed: allowed_kind,
204 requested: request.kind,
205 });
206 }
207
208 let member = ClusterMember::joined_empty(request.identity, request.kind);
210 let outcome = self.catalog.admit(member);
211 Ok(JoinGrant {
212 outcome,
213 snapshot: self.snapshot(),
214 })
215 }
216
217 fn snapshot(&self) -> ControlPlaneSnapshot {
218 ControlPlaneSnapshot {
219 cluster_id: self.catalog.cluster_id().clone(),
220 members: self.catalog.members().cloned().collect(),
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 fn ident(cn: &str) -> NodeIdentity {
230 NodeIdentity::from_certificate_subject(cn).unwrap()
231 }
232
233 fn cid() -> ClusterId {
234 ClusterId::new("cluster-prod").unwrap()
235 }
236
237 fn seed_with_pending_node_c() -> SeedAuthority {
239 let catalog = MembershipCatalog::new(
240 cid(),
241 [
242 ClusterMember::joined_empty(ident("CN=node-a"), MemberKind::Data),
243 ClusterMember::joined_empty(ident("CN=node-b"), MemberKind::Data),
244 ],
245 );
246 SeedAuthority::new(catalog, [(ident("CN=node-c"), MemberKind::Data)])
247 }
248
249 #[test]
250 fn successful_join_admits_authorized_data_member() {
251 let mut seed = seed_with_pending_node_c();
252 let req = JoinRequest::authenticated(cid(), ident("CN=node-c"), MemberKind::Data);
253
254 let grant = seed
255 .evaluate_join(req)
256 .expect("authorized join should succeed");
257 assert_eq!(grant.outcome, AdmissionOutcome::Admitted);
258
259 assert!(seed.catalog().is_authorized(&ident("CN=node-c")));
262 assert_eq!(grant.snapshot.cluster_id, cid());
263 assert_eq!(grant.snapshot.members.len(), 3);
264 assert!(seed.catalog().assess_baseline().meets_baseline());
265 }
266
267 #[test]
268 fn joined_data_member_starts_with_no_user_ranges() {
269 let mut seed = seed_with_pending_node_c();
270 let req = JoinRequest::authenticated(cid(), ident("CN=node-c"), MemberKind::Data);
271 seed.evaluate_join(req).unwrap();
272
273 let joined = seed.catalog().member(&ident("CN=node-c")).unwrap();
274 assert!(!joined.holds_user_ranges());
275 assert_eq!(joined.owned_range_count(), 0);
276 }
277
278 #[test]
279 fn unauthorized_peer_is_rejected() {
280 let mut seed = seed_with_pending_node_c();
281 let req = JoinRequest::authenticated(cid(), ident("CN=node-x"), MemberKind::Data);
284
285 let err = seed
286 .evaluate_join(req)
287 .expect_err("unknown peer must be rejected");
288 assert_eq!(err, JoinRejection::UnauthorizedPeer(ident("CN=node-x")));
289 assert!(!seed.catalog().is_autodetect_eligible(&ident("CN=node-x")));
291 assert_eq!(seed.catalog().len(), 2);
292 }
293
294 #[test]
295 fn wrong_cluster_join_is_rejected() {
296 let mut seed = seed_with_pending_node_c();
297 let other = ClusterId::new("cluster-staging").unwrap();
298 let req = JoinRequest::authenticated(other.clone(), ident("CN=node-c"), MemberKind::Data);
300
301 let err = seed
302 .evaluate_join(req)
303 .expect_err("wrong-cluster join must be rejected");
304 assert_eq!(
305 err,
306 JoinRejection::WrongCluster {
307 expected: cid(),
308 presented: other,
309 }
310 );
311 assert!(!seed.catalog().is_authorized(&ident("CN=node-c")));
312 }
313
314 #[test]
315 fn kind_mismatch_is_rejected() {
316 let mut seed = seed_with_pending_node_c();
317 let req = JoinRequest::authenticated(cid(), ident("CN=node-c"), MemberKind::Witness);
319
320 let err = seed
321 .evaluate_join(req)
322 .expect_err("kind mismatch must be rejected");
323 assert_eq!(
324 err,
325 JoinRejection::KindMismatch {
326 identity: ident("CN=node-c"),
327 allowed: MemberKind::Data,
328 requested: MemberKind::Witness,
329 }
330 );
331 }
332
333 #[test]
334 fn rejoin_is_idempotent() {
335 let mut seed = seed_with_pending_node_c();
336 let req = || JoinRequest::authenticated(cid(), ident("CN=node-c"), MemberKind::Data);
337
338 let first = seed.evaluate_join(req()).unwrap();
339 assert_eq!(first.outcome, AdmissionOutcome::Admitted);
340
341 let second = seed.evaluate_join(req()).unwrap();
342 assert_eq!(second.outcome, AdmissionOutcome::AlreadyMember);
343 assert_eq!(seed.catalog().len(), 3);
344 }
345
346 #[test]
347 fn autodetect_adopts_only_members_after_join() {
348 let mut seed = seed_with_pending_node_c();
349 assert!(!seed.catalog().is_autodetect_eligible(&ident("CN=node-c")));
351
352 seed.evaluate_join(JoinRequest::authenticated(
353 cid(),
354 ident("CN=node-c"),
355 MemberKind::Data,
356 ))
357 .unwrap();
358
359 assert!(seed.catalog().is_autodetect_eligible(&ident("CN=node-c")));
361 assert!(!seed.catalog().is_autodetect_eligible(&ident("CN=stranger")));
362 }
363}