Skip to main content

admin/
auth.rs

1use actrix_proto::{
2    CreateRealmRequest, CreateRealmResponse, DeleteConfigOverrideRequest,
3    DeleteConfigOverrideResponse, DeleteRealmRequest, DeleteRealmResponse, GetConfigRequest,
4    GetConfigResponse, GetNodeInfoRequest, GetNodeInfoResponse, GetRealmRequest, GetRealmResponse,
5    ListConfigOverridesRequest, ListConfigOverridesResponse, ListRealmsRequest, ListRealmsResponse,
6    NodeAdminService, NonceCredential, SetConfigOverrideRequest, SetConfigOverrideResponse,
7    ShutdownRequest, ShutdownResponse, UpdateConfigRequest, UpdateConfigResponse,
8    UpdateRealmRequest, UpdateRealmResponse,
9};
10use nonce_auth::{CredentialVerifier, NonceError, storage::NonceStorage};
11use std::sync::Arc;
12use std::time::Duration;
13use tonic::{Request, Response, Status};
14
15/// 请求体需要提供认证载荷与凭证
16pub trait CredentialPayload {
17    fn credential(&self) -> &NonceCredential;
18    fn auth_payload(&self, node_id: &str) -> String;
19    /// Digest of the request body (excluding the `credential` field) for mutable
20    /// operations. Appended to `auth_payload` as `{op}:{node_id}:{subject}:{digest}`
21    /// so the signature binds the security-critical body fields (e.g. realm secret
22    /// hashes) against in-transit tampering. Returns empty for read-only ops, which
23    /// have no mutable body beyond what `auth_payload` already binds.
24    fn body_digest(&self) -> String {
25        String::new()
26    }
27}
28
29/// SHA-256 hex digest of a canonical string.
30fn sha256_hex(s: &str) -> String {
31    use sha2::{Digest, Sha256};
32    let mut hasher = Sha256::new();
33    hasher.update(s.as_bytes());
34    hex::encode(hasher.finalize())
35}
36
37fn opt_str(v: Option<&str>) -> &str {
38    v.unwrap_or("")
39}
40
41fn opt_u64(v: Option<u64>) -> String {
42    v.map(|x| x.to_string()).unwrap_or_default()
43}
44
45fn opt_i32(v: Option<i32>) -> String {
46    v.map(|x| x.to_string()).unwrap_or_default()
47}
48
49fn opt_bool(v: Option<bool>) -> String {
50    v.map(|b| b.to_string()).unwrap_or_default()
51}
52
53#[derive(Clone)]
54struct VerifierState {
55    node_id: String,
56    shared_secret: Arc<Vec<u8>>,
57    nonce_storage: Arc<dyn NonceStorage + Send + Sync>,
58    max_clock_skew_secs: u64,
59}
60
61impl VerifierState {
62    async fn verify(&self, credential: &NonceCredential, payload: String) -> Result<(), Status> {
63        let nonce_credential = nonce_auth::NonceCredential {
64            timestamp: credential.timestamp,
65            nonce: credential.nonce.clone(),
66            signature: credential.signature.clone(),
67        };
68
69        let verifier = CredentialVerifier::new(self.nonce_storage.clone())
70            .with_secret(&self.shared_secret)
71            .with_time_window(Duration::from_secs(self.max_clock_skew_secs))
72            .with_storage_ttl(Duration::from_secs(self.max_clock_skew_secs + 300));
73
74        verifier
75            .verify(&nonce_credential, payload.as_bytes())
76            .await
77            .map_err(|e| map_nonce_error(e, "credential verification failed"))
78    }
79}
80
81/// 在进入业务实现前统一做 NonceCredential 校验的包装服务
82#[derive(Clone)]
83pub struct AuthService<S> {
84    inner: S,
85    verifier: Arc<VerifierState>,
86}
87
88impl<S> AuthService<S> {
89    pub fn new(
90        inner: S,
91        node_id: impl Into<String>,
92        shared_secret: Arc<Vec<u8>>,
93        nonce_storage: Arc<dyn NonceStorage + Send + Sync>,
94        max_clock_skew_secs: u64,
95    ) -> Self {
96        let time_window = if max_clock_skew_secs == 0 {
97            300
98        } else {
99            max_clock_skew_secs
100        };
101
102        Self {
103            inner,
104            verifier: Arc::new(VerifierState {
105                node_id: node_id.into(),
106                shared_secret,
107                nonce_storage,
108                max_clock_skew_secs: time_window,
109            }),
110        }
111    }
112
113    async fn verify_body<T: CredentialPayload>(&self, body: &T) -> Result<(), Status> {
114        let mut payload = body.auth_payload(&self.verifier.node_id);
115        let digest = body.body_digest();
116        if !digest.is_empty() {
117            payload.push(':');
118            payload.push_str(&digest);
119        }
120        self.verifier.verify(body.credential(), payload).await
121    }
122}
123
124#[tonic::async_trait]
125impl<S> NodeAdminService for AuthService<S>
126where
127    S: NodeAdminService + Send + Sync + Clone + 'static,
128{
129    async fn update_config(
130        &self,
131        request: Request<UpdateConfigRequest>,
132    ) -> Result<Response<UpdateConfigResponse>, Status> {
133        self.verify_body(request.get_ref()).await?;
134        self.inner.update_config(request).await
135    }
136
137    async fn get_config(
138        &self,
139        request: Request<GetConfigRequest>,
140    ) -> Result<Response<GetConfigResponse>, Status> {
141        self.verify_body(request.get_ref()).await?;
142        self.inner.get_config(request).await
143    }
144
145    async fn create_realm(
146        &self,
147        request: Request<CreateRealmRequest>,
148    ) -> Result<Response<CreateRealmResponse>, Status> {
149        self.verify_body(request.get_ref()).await?;
150        self.inner.create_realm(request).await
151    }
152
153    async fn get_realm(
154        &self,
155        request: Request<GetRealmRequest>,
156    ) -> Result<Response<GetRealmResponse>, Status> {
157        self.verify_body(request.get_ref()).await?;
158        self.inner.get_realm(request).await
159    }
160
161    async fn update_realm(
162        &self,
163        request: Request<UpdateRealmRequest>,
164    ) -> Result<Response<UpdateRealmResponse>, Status> {
165        self.verify_body(request.get_ref()).await?;
166        self.inner.update_realm(request).await
167    }
168
169    async fn delete_realm(
170        &self,
171        request: Request<DeleteRealmRequest>,
172    ) -> Result<Response<DeleteRealmResponse>, Status> {
173        self.verify_body(request.get_ref()).await?;
174        self.inner.delete_realm(request).await
175    }
176
177    async fn list_realms(
178        &self,
179        request: Request<ListRealmsRequest>,
180    ) -> Result<Response<ListRealmsResponse>, Status> {
181        self.verify_body(request.get_ref()).await?;
182        self.inner.list_realms(request).await
183    }
184
185    async fn get_node_info(
186        &self,
187        request: Request<GetNodeInfoRequest>,
188    ) -> Result<Response<GetNodeInfoResponse>, Status> {
189        self.verify_body(request.get_ref()).await?;
190        self.inner.get_node_info(request).await
191    }
192
193    async fn shutdown(
194        &self,
195        request: Request<ShutdownRequest>,
196    ) -> Result<Response<ShutdownResponse>, Status> {
197        self.verify_body(request.get_ref()).await?;
198        self.inner.shutdown(request).await
199    }
200
201    async fn list_config_overrides(
202        &self,
203        request: Request<ListConfigOverridesRequest>,
204    ) -> Result<Response<ListConfigOverridesResponse>, Status> {
205        self.verify_body(request.get_ref()).await?;
206        self.inner.list_config_overrides(request).await
207    }
208
209    async fn set_config_override(
210        &self,
211        request: Request<SetConfigOverrideRequest>,
212    ) -> Result<Response<SetConfigOverrideResponse>, Status> {
213        self.verify_body(request.get_ref()).await?;
214        self.inner.set_config_override(request).await
215    }
216
217    async fn delete_config_override(
218        &self,
219        request: Request<DeleteConfigOverrideRequest>,
220    ) -> Result<Response<DeleteConfigOverrideResponse>, Status> {
221        self.verify_body(request.get_ref()).await?;
222        self.inner.delete_config_override(request).await
223    }
224}
225
226// ========= 请求类型的载荷构造实现 =========
227
228impl CredentialPayload for UpdateConfigRequest {
229    fn credential(&self) -> &NonceCredential {
230        &self.credential
231    }
232
233    fn auth_payload(&self, node_id: &str) -> String {
234        format!(
235            "update_config:{node_id}:{}:{}",
236            self.config_type, self.config_key
237        )
238    }
239}
240
241impl CredentialPayload for GetConfigRequest {
242    fn credential(&self) -> &NonceCredential {
243        &self.credential
244    }
245
246    fn auth_payload(&self, node_id: &str) -> String {
247        format!(
248            "get_config:{node_id}:{}:{}",
249            self.config_type, self.config_key
250        )
251    }
252}
253
254impl CredentialPayload for CreateRealmRequest {
255    fn credential(&self) -> &NonceCredential {
256        &self.credential
257    }
258
259    fn auth_payload(&self, node_id: &str) -> String {
260        format!("create_realm:{node_id}:{}", self.realm_id.unwrap_or(0))
261    }
262
263    fn body_digest(&self) -> String {
264        let canon = format!(
265            "realm_id={}&name={}&enabled={}&status={}&expires_at={}&secret_current_hash={}&secret_previous_hash={}&secret_previous_valid_until={}",
266            self.realm_id.unwrap_or(0),
267            self.name,
268            self.enabled,
269            opt_str(self.status.as_deref()),
270            self.expires_at,
271            opt_str(self.secret_current_hash.as_deref()),
272            opt_str(self.secret_previous_hash.as_deref()),
273            opt_u64(self.secret_previous_valid_until),
274        );
275        sha256_hex(&canon)
276    }
277}
278
279impl CredentialPayload for GetRealmRequest {
280    fn credential(&self) -> &NonceCredential {
281        &self.credential
282    }
283
284    fn auth_payload(&self, node_id: &str) -> String {
285        format!("get_realm:{node_id}:{}", self.realm_id)
286    }
287}
288
289impl CredentialPayload for UpdateRealmRequest {
290    fn credential(&self) -> &NonceCredential {
291        &self.credential
292    }
293
294    fn auth_payload(&self, node_id: &str) -> String {
295        format!("update_realm:{node_id}:{}", self.realm_id)
296    }
297
298    fn body_digest(&self) -> String {
299        let canon = format!(
300            "realm_id={}&name={}&enabled={}&status={}&expires_at={}&secret_current_hash={}&secret_previous_hash={}&secret_previous_valid_until={}",
301            self.realm_id,
302            opt_str(self.name.as_deref()),
303            opt_bool(self.enabled),
304            opt_str(self.status.as_deref()),
305            opt_u64(self.expires_at),
306            opt_str(self.secret_current_hash.as_deref()),
307            opt_str(self.secret_previous_hash.as_deref()),
308            opt_u64(self.secret_previous_valid_until),
309        );
310        sha256_hex(&canon)
311    }
312}
313
314impl CredentialPayload for DeleteRealmRequest {
315    fn credential(&self) -> &NonceCredential {
316        &self.credential
317    }
318
319    fn auth_payload(&self, node_id: &str) -> String {
320        format!("delete_realm:{node_id}:{}", self.realm_id)
321    }
322}
323
324impl CredentialPayload for ListRealmsRequest {
325    fn credential(&self) -> &NonceCredential {
326        &self.credential
327    }
328
329    fn auth_payload(&self, node_id: &str) -> String {
330        format!("list_realms:{node_id}")
331    }
332}
333
334impl CredentialPayload for GetNodeInfoRequest {
335    fn credential(&self) -> &NonceCredential {
336        &self.credential
337    }
338
339    fn auth_payload(&self, node_id: &str) -> String {
340        format!("node_info:{node_id}")
341    }
342}
343
344impl CredentialPayload for ShutdownRequest {
345    fn credential(&self) -> &NonceCredential {
346        &self.credential
347    }
348
349    fn auth_payload(&self, node_id: &str) -> String {
350        format!("shutdown:{node_id}")
351    }
352
353    fn body_digest(&self) -> String {
354        let canon = format!(
355            "graceful={}&timeout_secs={}&reason={}",
356            self.graceful,
357            opt_i32(self.timeout_secs),
358            opt_str(self.reason.as_deref()),
359        );
360        sha256_hex(&canon)
361    }
362}
363
364impl CredentialPayload for ListConfigOverridesRequest {
365    fn credential(&self) -> &NonceCredential {
366        &self.credential
367    }
368
369    fn auth_payload(&self, node_id: &str) -> String {
370        format!("list_config_overrides:{node_id}")
371    }
372}
373
374impl CredentialPayload for SetConfigOverrideRequest {
375    fn credential(&self) -> &NonceCredential {
376        &self.credential
377    }
378
379    fn auth_payload(&self, node_id: &str) -> String {
380        format!("set_config_override:{node_id}:{}", self.key)
381    }
382}
383
384impl CredentialPayload for DeleteConfigOverrideRequest {
385    fn credential(&self) -> &NonceCredential {
386        &self.credential
387    }
388
389    fn auth_payload(&self, node_id: &str) -> String {
390        format!("delete_config_override:{node_id}:{}", self.key)
391    }
392}
393
394fn map_nonce_error(err: NonceError, context: &str) -> Status {
395    match err {
396        NonceError::DuplicateNonce => {
397            Status::unauthenticated(format!("{context}: nonce already used"))
398        }
399        NonceError::TimestampOutOfWindow => {
400            Status::unauthenticated(format!("{context}: timestamp out of range"))
401        }
402        NonceError::InvalidSignature => {
403            Status::unauthenticated(format!("{context}: invalid signature"))
404        }
405        other => Status::internal(format!("{context}: {other}")),
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    /// Canonical body string for realm requests is the cross-repo contract:
414    /// the superv client must build the identical string for the same logical
415    /// fields. Any change to field order/rendering here must be mirrored on the
416    /// superv side (see its `realm_body_digest` test, same expected digest).
417    const REALM_CANON: &str = "realm_id=42&name=managed&enabled=true&status=Active&expires_at=1900000000&secret_current_hash=hash-42&secret_previous_hash=&secret_previous_valid_until=";
418
419    fn dummy_credential() -> NonceCredential {
420        NonceCredential {
421            timestamp: 0,
422            nonce: String::new(),
423            signature: String::new(),
424        }
425    }
426
427    #[test]
428    fn create_realm_body_digest_matches_canonical() {
429        let req = CreateRealmRequest {
430            realm_id: Some(42),
431            name: "managed".to_string(),
432            enabled: true,
433            credential: dummy_credential(),
434            expires_at: 1_900_000_000,
435            status: Some("Active".to_string()),
436            secret_current_hash: Some("hash-42".to_string()),
437            secret_previous_hash: None,
438            secret_previous_valid_until: None,
439        };
440        assert_eq!(sha256_hex(REALM_CANON), req.body_digest());
441    }
442
443    #[test]
444    fn update_realm_body_digest_matches_canonical() {
445        let req = UpdateRealmRequest {
446            realm_id: 42,
447            name: Some("managed".to_string()),
448            enabled: Some(true),
449            credential: dummy_credential(),
450            status: Some("Active".to_string()),
451            expires_at: Some(1_900_000_000),
452            secret_current_hash: Some("hash-42".to_string()),
453            secret_previous_hash: None,
454            secret_previous_valid_until: None,
455        };
456        assert_eq!(sha256_hex(REALM_CANON), req.body_digest());
457    }
458
459    #[test]
460    fn shutdown_body_digest_matches_canonical() {
461        let req = ShutdownRequest {
462            graceful: true,
463            timeout_secs: Some(30),
464            reason: Some("deploy".to_string()),
465            credential: dummy_credential(),
466        };
467        let canon = "graceful=true&timeout_secs=30&reason=deploy";
468        assert_eq!(sha256_hex(canon), req.body_digest());
469    }
470
471    #[test]
472    fn read_ops_have_empty_body_digest() {
473        let get_realm = GetRealmRequest {
474            realm_id: 42,
475            credential: dummy_credential(),
476        };
477        assert_eq!(get_realm.body_digest(), "");
478    }
479}