Skip to main content

secrets_engine_m365/
lib.rs

1//! Microsoft 365 / Graph app-only access tokens.
2//!
3//! Microsoft is the provider that can be operated with **no stored secret at
4//! all** — a federated identity credential on the app registration lets a
5//! workload's own OIDC token stand in for a client secret. It is also a
6//! provider whose issued tokens **cannot be revoked**, so the containment
7//! story is entirely TTL plus resource-level scoping.
8//!
9//! See `docs/delegation/microsoft-365.md` for the mechanism and
10//! `docs/delegation/setup/microsoft-365.md` for the operator walkthrough.
11
12use async_trait::async_trait;
13use chrono::{DateTime, Utc};
14use secrets_core::engine::{
15    CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
16    SecretsEngine, TtlDoc,
17};
18use secrets_core::lease::Lease;
19use secrets_core::mount::ConfigRoleStore;
20use secrets_core::storage::StorageBackend;
21use serde::{Deserialize, Serialize};
22use serde_json::json;
23use uuid::Uuid;
24
25const STORE: ConfigRoleStore = ConfigRoleStore::new("m365/config/", "m365/roles/");
26const MOUNT: &str = "m365/creds/";
27const DEFAULT_AUTHORITY: &str = "https://login.microsoftonline.com";
28const DEFAULT_SCOPE: &str = "https://graph.microsoft.com/.default";
29const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
30
31/// Our own client assertion only has to survive one token request, so it gets
32/// the shortest life that tolerates clock skew. A long-lived assertion is a
33/// bearer credential for the whole app registration.
34const CLIENT_ASSERTION_TTL_SECONDS: i64 = 300;
35
36/// Configurable Token Lifetime's floor. Quoted in `doc()` because with
37/// revocation unavailable, shortening the token is the only real control.
38const MIN_CONFIGURABLE_TTL_SECONDS: i64 = 600;
39/// Configurable Token Lifetime's ceiling, 23:59:59.
40const MAX_CONFIGURABLE_TTL_SECONDS: i64 = 86_399;
41
42/// How the server proves it is the app registration.
43///
44/// `Federated` is the one to reach for: the assertion is the workload's own
45/// platform-issued identity token, so there is no durable secret to store,
46/// rotate or leak.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48#[serde(tag = "type", rename_all = "snake_case")]
49pub enum CredentialKind {
50    /// The ordinary confidential-client secret. Expires, and must be rotated
51    /// by hand.
52    Secret { client_secret: String },
53    /// A certificate credential: we sign the assertion ourselves, so the key
54    /// never crosses the wire. `x5t_s256` is the certificate's base64url
55    /// SHA-256 thumbprint, which Entra uses to pick the registered public key.
56    Certificate {
57        private_key_pem: String,
58        x5t_s256: String,
59    },
60    /// Workload identity federation. `token_file` is where the platform
61    /// projects the workload's OIDC token — on Kubernetes this is the path
62    /// `AZURE_FEDERATED_TOKEN_FILE` points at.
63    Federated { token_file: String },
64}
65
66impl CredentialKind {
67    /// What the server had to be trusted with, for the `_doc` block and for
68    /// `root_credential`.
69    fn describes_root_credential(&self) -> &'static str {
70        match self {
71            Self::Secret { .. } => "a client secret",
72            Self::Certificate { .. } => "a certificate private key",
73            Self::Federated { .. } => "nothing — the workload's own OIDC token",
74        }
75    }
76}
77
78/// The app registration this engine authenticates as.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct M365Config {
81    pub tenant_id: String,
82    pub client_id: String,
83    pub credential: CredentialKind,
84    /// Override for the sovereign clouds, which use their own login hosts.
85    #[serde(default = "default_authority")]
86    pub authority: String,
87}
88
89fn default_authority() -> String {
90    DEFAULT_AUTHORITY.to_string()
91}
92
93fn default_scope() -> String {
94    DEFAULT_SCOPE.to_string()
95}
96
97/// What one consumer may mint.
98///
99/// There is deliberately no TTL setting: Entra decides the lifetime, and the
100/// only way to shorten it is a tenant-side Configurable Token Lifetime policy.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct RoleConfig {
103    /// Which `m365/config/{name}` document to authenticate with.
104    pub target: String,
105    /// Practically always `…/.default`: client credentials cannot request a
106    /// subset of the app's permissions.
107    #[serde(default = "default_scope")]
108    pub scope: String,
109    /// Free-text note about which resource the operator narrowed this app to
110    /// (a mail security group, a `Sites.Selected` site, a Team). Entra does not
111    /// report the narrowing back to us, so recording it here is the only way
112    /// the `_doc` block can tell a consumer what it really got.
113    #[serde(default)]
114    pub resource_hint: Option<String>,
115}
116
117#[derive(Debug, Deserialize)]
118struct TokenResponse {
119    access_token: String,
120    expires_in: i64,
121    #[serde(default)]
122    token_type: Option<String>,
123}
124
125#[derive(Debug, Serialize, PartialEq, Eq)]
126struct ClientAssertionClaims {
127    /// Must be the exact token endpoint being called, or Entra rejects it.
128    aud: String,
129    iss: String,
130    sub: String,
131    jti: String,
132    iat: i64,
133    nbf: i64,
134    exp: i64,
135}
136
137#[derive(Default)]
138pub struct M365Engine {
139    http: reqwest::Client,
140}
141
142impl M365Engine {
143    pub fn new() -> Self {
144        Self::default()
145    }
146
147    fn token_endpoint(config: &M365Config) -> String {
148        format!(
149            "{}/{}/oauth2/v2.0/token",
150            config.authority.trim_end_matches('/'),
151            config.tenant_id
152        )
153    }
154
155    fn assertion_claims(
156        client_id: &str,
157        token_endpoint: &str,
158        now: DateTime<Utc>,
159    ) -> ClientAssertionClaims {
160        ClientAssertionClaims {
161            aud: token_endpoint.to_string(),
162            // Entra requires the app to be both issuer and subject of its own
163            // assertion.
164            iss: client_id.to_string(),
165            sub: client_id.to_string(),
166            jti: Uuid::new_v4().to_string(),
167            iat: now.timestamp(),
168            nbf: now.timestamp(),
169            exp: now.timestamp() + CLIENT_ASSERTION_TTL_SECONDS,
170        }
171    }
172
173    /// The thumbprint goes in the header rather than the claims: it is how
174    /// Entra selects which registered public key to verify against.
175    fn assertion_header(x5t_s256: &str) -> jsonwebtoken::Header {
176        let mut header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256);
177        header.x5t_s256 = Some(x5t_s256.to_string());
178        header
179    }
180
181    fn client_assertion(
182        client_id: &str,
183        private_key_pem: &str,
184        x5t_s256: &str,
185        token_endpoint: &str,
186        now: DateTime<Utc>,
187    ) -> EngineResult<String> {
188        let key = jsonwebtoken::EncodingKey::from_rsa_pem(private_key_pem.as_bytes()).map_err(|e| {
189            EngineError::InvalidRequest(format!(
190                "m365/config credential.private_key_pem is not a valid RSA PEM: {e}"
191            ))
192        })?;
193        jsonwebtoken::encode(
194            &Self::assertion_header(x5t_s256),
195            &Self::assertion_claims(client_id, token_endpoint, now),
196            &key,
197        )
198        .map_err(|e| EngineError::Other(format!("failed to sign the client assertion: {e}")))
199    }
200
201    /// Read at every mint rather than cached: the platform rotates this file
202    /// on its own schedule, and a stale token is rejected.
203    fn read_federated_token(token_file: &str) -> EngineResult<String> {
204        let raw = std::fs::read_to_string(token_file).map_err(|e| {
205            EngineError::InvalidRequest(format!(
206                "cannot read the federated token file '{token_file}': {e}. On Kubernetes \
207                 this is the path AZURE_FEDERATED_TOKEN_FILE points at, and it must be \
208                 projected into this server's own pod."
209            ))
210        })?;
211        let token = raw.trim().to_string();
212        if token.is_empty() {
213            return Err(EngineError::InvalidRequest(format!(
214                "the federated token file '{token_file}' is empty"
215            )));
216        }
217        Ok(token)
218    }
219
220    async fn request_access_token(
221        &self,
222        config: &M365Config,
223        scope: &str,
224        now: DateTime<Utc>,
225    ) -> EngineResult<TokenResponse> {
226        let endpoint = Self::token_endpoint(config);
227        let mut form: Vec<(&str, String)> = vec![
228            ("grant_type", "client_credentials".to_string()),
229            ("client_id", config.client_id.clone()),
230            ("scope", scope.to_string()),
231        ];
232
233        match &config.credential {
234            CredentialKind::Secret { client_secret } => {
235                form.push(("client_secret", client_secret.clone()));
236            }
237            CredentialKind::Certificate {
238                private_key_pem,
239                x5t_s256,
240            } => {
241                let assertion = Self::client_assertion(
242                    &config.client_id,
243                    private_key_pem,
244                    x5t_s256,
245                    &endpoint,
246                    now,
247                )?;
248                form.push(("client_assertion_type", CLIENT_ASSERTION_TYPE.to_string()));
249                form.push(("client_assertion", assertion));
250            }
251            CredentialKind::Federated { token_file } => {
252                let assertion = Self::read_federated_token(token_file)?;
253                form.push(("client_assertion_type", CLIENT_ASSERTION_TYPE.to_string()));
254                form.push(("client_assertion", assertion));
255            }
256        }
257
258        let response = self
259            .http
260            .post(&endpoint)
261            .form(&form)
262            .send()
263            .await
264            .map_err(|e| EngineError::Provider(format!("Entra ID request failed: {e}")))?;
265
266        let status = response.status();
267        let text = response.text().await.unwrap_or_default();
268        if !status.is_success() {
269            // Entra's error_description is the only place it says *why* — most
270            // often that nobody granted admin consent.
271            return Err(EngineError::Provider(format!(
272                "Entra ID returned {status} for {endpoint}: {text}"
273            )));
274        }
275        serde_json::from_str(&text)
276            .map_err(|e| EngineError::Provider(format!("unexpected Entra ID response: {e}")))
277    }
278
279    fn scope_description(role: &RoleConfig, config: &M365Config) -> Vec<String> {
280        let mut scoped = vec![
281            format!("tenant:{}", config.tenant_id),
282            format!("app:{}", config.client_id),
283            format!("scope:{}", role.scope),
284        ];
285        if role.scope.ends_with("/.default") {
286            // Saying this out loud matters: a consumer could otherwise read
287            // "scope: Graph" and assume it was narrowed when it was not.
288            scoped.push(
289                "permissions:ALL application permissions consented to this app — \
290                 .default cannot request a subset, so the app registration is the \
291                 only boundary the token itself carries"
292                    .to_string(),
293            );
294        }
295        match &role.resource_hint {
296            Some(hint) => scoped.push(format!("resource-narrowing:{hint}")),
297            None => scoped.push(
298                "resource-narrowing:NONE RECORDED — app-only Graph permissions are \
299                 tenant-wide unless an admin narrowed them"
300                    .to_string(),
301            ),
302        }
303        scoped
304    }
305}
306
307#[async_trait]
308impl SecretsEngine for M365Engine {
309    fn doc(&self) -> EngineDoc {
310        EngineDoc {
311            provider: "Microsoft 365 (Microsoft Graph)".to_string(),
312            mechanism: "app-only OAuth 2 client-credentials access tokens from Entra \
313                        ID, authenticated with a client secret, a certificate \
314                        assertion, or — with no stored secret — a federated identity \
315                        credential"
316                .to_string(),
317            shape: CredentialShape::MintExpiryOnly,
318            revocable: false,
319            revoke_effect: "nothing at the provider. No Microsoft API revokes an \
320                            issued Graph access token, so revoking a lease only \
321                            deletes our record of it and stops renewal — the token \
322                            keeps working until it expires. revokeSignInSessions \
323                            invalidates refresh tokens and future issuance, not live \
324                            access tokens, and Continuous Access Evaluation is the \
325                            only near-real-time path: it needs both the resource and \
326                            the client to be CAE-capable, and reacts only to critical \
327                            events such as the identity being disabled."
328                .to_string(),
329            ttl: TtlDoc::range(
330                MIN_CONFIGURABLE_TTL_SECONDS,
331                MAX_CONFIGURABLE_TTL_SECONDS,
332                "Entra decides, and randomises the default between 60 and 90 minutes, \
333                 so the lease is set from the returned expires_in rather than any \
334                 figure we choose. A tenant-side Configurable Token Lifetime policy \
335                 can pin it between 10 minutes and 23:59:59; 10 minutes is the \
336                 defensible choice here precisely because revocation is unavailable.",
337            ),
338            scoping: "not in the token. Client credentials can only ask for \
339                      …/.default, which carries every application permission the app \
340                      has been granted, so narrowing has to happen at the resource: \
341                      an Exchange Application Access Policy for mail, Sites.Selected \
342                      for SharePoint and OneDrive, resource-specific consent for \
343                      Teams. Record what you did in the role's resource_hint, since \
344                      Entra does not report it back to us."
345                .to_string(),
346            root_credential: "depends on m365/config/{target}.credential: a client \
347                              secret or a certificate private key — or, with the \
348                              federated variant, nothing at all. Prefer federated: \
349                              the assertion is this server's own platform-issued OIDC \
350                              token, so there is no durable secret to store, rotate \
351                              or leak, and only the trust configuration on the app \
352                              registration is durable."
353                .to_string(),
354            paths: vec![
355                PathDoc::new(
356                    "m365/config/{target}",
357                    &["POST", "GET", "DELETE"],
358                    "sudo",
359                    "register the tenant, client id and credential. GET reports only \
360                     whether it is configured — the credential is never returned.",
361                ),
362                PathDoc::new(
363                    "m365/roles/{role}",
364                    &["POST", "GET", "DELETE"],
365                    "create / read / sudo",
366                    "define one consumer's target, scope and recorded resource narrowing",
367                ),
368                PathDoc::new(
369                    "m365/creds/{role}",
370                    &["GET"],
371                    "read",
372                    "mint a Graph access token and open a lease",
373                ),
374                PathDoc::new("m365/help", &["GET"], "authenticated", "this document"),
375            ],
376            docs_url: Some("docs/delegation/microsoft-365.md".to_string()),
377            caveats: vec![
378                "…/.default is the only usable scope for client credentials, so the \
379                 app registration IS the scope boundary — you cannot ask for less at \
380                 request time, and a consumer receives everything the app was \
381                 consented."
382                    .to_string(),
383                "App-only Graph permissions are tenant-wide by default. Mail is \
384                 narrowed with an Exchange Application Access Policy, whose \
385                 propagation can exceed an hour; SharePoint and OneDrive with \
386                 Sites.Selected, which grants zero sites until an admin grants \
387                 per-site roles — though the Graph Search API queries a tenant-wide \
388                 index and bypasses it; Teams with resource-specific consent."
389                    .to_string(),
390                "Because no issued token can be revoked, a Configurable Token \
391                 Lifetime policy is the real containment control. Its minimum is 10 \
392                 minutes."
393                    .to_string(),
394                "CAE-eligible tokens live 24–28 hours, far longer than the 60–90 \
395                 minute headline, precisely because they can be re-evaluated. Do not \
396                 assume the short figure holds everywhere."
397                    .to_string(),
398                "The federated variant removes the stored secret but does not make \
399                 this shape E: the consumer still receives a bearer token from us. \
400                 True federation means the consumer exchanging its own identity \
401                 directly — see the federation mount."
402                    .to_string(),
403                "Admin consent is required for application permissions, and a missing \
404                 consent surfaces as an opaque authorisation failure rather than \
405                 anything that says 'nobody clicked approve'."
406                    .to_string(),
407            ],
408        }
409    }
410
411    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
412        STORE.handle_read::<RoleConfig>(storage, path).await
413    }
414
415    async fn write(
416        &self,
417        storage: &dyn StorageBackend,
418        path: &str,
419        data: serde_json::Value,
420    ) -> EngineResult<()> {
421        STORE.handle_write::<M365Config, RoleConfig>(storage, path, data).await
422    }
423
424    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
425        STORE.handle_delete(storage, path).await
426    }
427
428    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
429        STORE.handle_list(storage, prefix).await
430    }
431
432    async fn generate(
433        &self,
434        storage: &dyn StorageBackend,
435        role_name: &str,
436    ) -> EngineResult<GeneratedCredential> {
437        let role: RoleConfig = STORE.require_role(storage, role_name).await?;
438        let config: M365Config = STORE.require_config(storage, &role.target).await?;
439
440        let now = Utc::now();
441        let token = self.request_access_token(&config, &role.scope, now).await?;
442
443        let lease = Lease {
444            id: Uuid::new_v4(),
445            // Set by the HTTP handler, which knows the requesting token.
446            token_id_hash: String::new(),
447            engine_mount: MOUNT.to_string(),
448            // Nothing here is needed to revoke, because nothing can be revoked.
449            // These fields exist so an operator reading a lease can tell which
450            // app minted it.
451            internal_data: json!({
452                "role": role_name,
453                "tenant_id": config.tenant_id,
454                "client_id": config.client_id,
455                "credential_type": config.credential.describes_root_credential(),
456            }),
457            issued_at: now,
458            // Entra's own lifetime, never a figure of ours: the default is
459            // randomised, so computing it locally would drift.
460            expires_at: now + chrono::Duration::seconds(token.expires_in),
461        };
462
463        Ok(GeneratedCredential::new(
464            json!({
465                "access_token": token.access_token,
466                "token_type": token.token_type.unwrap_or_else(|| "Bearer".to_string()),
467                "expires_in": token.expires_in,
468            }),
469            lease,
470            Self::scope_description(&role, &config),
471        ))
472    }
473
474    async fn revoke(&self, _storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
475        // Returning Ok is not a claim of success — it lets the reaper clear the
476        // lease record, which is the only thing that can actually be cleared.
477        // There is no Microsoft API that invalidates an issued access token.
478        tracing::warn!(
479            lease_id = %lease.id,
480            expires_at = %lease.expires_at,
481            "m365 lease revoked locally only: Microsoft cannot invalidate an issued \
482             Graph access token, so it remains usable until it expires"
483        );
484        Ok(())
485    }
486}
487
488#[cfg(test)]
489mod tests {
490    use super::*;
491
492    fn config(credential: CredentialKind) -> M365Config {
493        M365Config {
494            tenant_id: "00000000-0000-0000-0000-0000000000aa".to_string(),
495            client_id: "11111111-1111-1111-1111-1111111111bb".to_string(),
496            credential,
497            authority: default_authority(),
498        }
499    }
500
501    fn role() -> RoleConfig {
502        RoleConfig {
503            target: "acme".to_string(),
504            scope: default_scope(),
505            resource_hint: None,
506        }
507    }
508
509    #[test]
510    fn credential_kinds_round_trip_with_a_type_tag() {
511        let cases = [
512            (
513                CredentialKind::Secret {
514                    client_secret: "s3cret".to_string(),
515                },
516                "secret",
517            ),
518            (
519                CredentialKind::Certificate {
520                    private_key_pem: "pem".to_string(),
521                    x5t_s256: "thumb".to_string(),
522                },
523                "certificate",
524            ),
525            (
526                CredentialKind::Federated {
527                    token_file: "/var/run/token".to_string(),
528                },
529                "federated",
530            ),
531        ];
532
533        for (credential, tag) in cases {
534            let value = serde_json::to_value(&credential).unwrap();
535            assert_eq!(value["type"], tag, "unexpected tag for {credential:?}");
536
537            let parsed: CredentialKind = serde_json::from_value(value).unwrap();
538            assert_eq!(
539                serde_json::to_value(&parsed).unwrap(),
540                serde_json::to_value(&credential).unwrap(),
541                "{tag} did not survive a round trip"
542            );
543        }
544    }
545
546    /// The operator-facing config in the docs must actually deserialise.
547    #[test]
548    fn config_parses_the_documented_shape() {
549        let parsed: M365Config = serde_json::from_value(json!({
550            "tenant_id": "tenant",
551            "client_id": "client",
552            "credential": { "type": "federated", "token_file": "/var/run/secrets/azure/token" },
553        }))
554        .unwrap();
555        assert_eq!(parsed.authority, DEFAULT_AUTHORITY);
556        assert!(matches!(parsed.credential, CredentialKind::Federated { .. }));
557    }
558
559    #[test]
560    fn token_endpoint_is_the_v2_tenant_endpoint() {
561        let config = config(CredentialKind::Secret {
562            client_secret: "x".to_string(),
563        });
564        assert_eq!(
565            M365Engine::token_endpoint(&config),
566            format!(
567                "https://login.microsoftonline.com/{}/oauth2/v2.0/token",
568                config.tenant_id
569            )
570        );
571    }
572
573    /// A long-lived assertion would itself be a credential for the whole app
574    /// registration, and `aud` has to be the exact endpoint or Entra rejects it.
575    #[test]
576    fn client_assertion_claims_are_short_lived_and_endpoint_bound() {
577        let config = config(CredentialKind::Secret {
578            client_secret: "x".to_string(),
579        });
580        let endpoint = M365Engine::token_endpoint(&config);
581        let now = Utc::now();
582        let claims = M365Engine::assertion_claims(&config.client_id, &endpoint, now);
583
584        assert_eq!(claims.aud, endpoint);
585        assert_eq!(claims.iss, config.client_id);
586        assert_eq!(claims.sub, config.client_id);
587        assert_eq!(claims.exp - claims.iat, CLIENT_ASSERTION_TTL_SECONDS);
588        assert!(claims.exp - claims.iat <= 600, "assertion must be short-lived");
589
590        let other = M365Engine::assertion_claims(&config.client_id, &endpoint, now);
591        assert_ne!(claims.jti, other.jti, "jti must be unique per assertion");
592    }
593
594    #[test]
595    fn assertion_header_carries_the_thumbprint_as_x5t_s256() {
596        let header = M365Engine::assertion_header("Zm9vYmFy");
597        assert_eq!(header.alg, jsonwebtoken::Algorithm::RS256);
598
599        let encoded = serde_json::to_value(&header).unwrap();
600        assert_eq!(
601            encoded["x5t#S256"], "Zm9vYmFy",
602            "Entra selects the registered key by this header, so the RFC spelling matters"
603        );
604    }
605
606    #[test]
607    fn rejects_a_certificate_that_is_not_a_pem() {
608        let err = M365Engine::client_assertion(
609            "client",
610            "not a pem",
611            "thumb",
612            "https://login.microsoftonline.com/t/oauth2/v2.0/token",
613            Utc::now(),
614        )
615        .unwrap_err();
616        assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
617    }
618
619    #[test]
620    fn missing_federated_token_file_is_a_clear_invalid_request() {
621        let path = format!("/nonexistent/{}/token", Uuid::new_v4());
622        let err = M365Engine::read_federated_token(&path).unwrap_err();
623        match err {
624            EngineError::InvalidRequest(message) => {
625                assert!(message.contains(&path), "error should name the path: {message}");
626                assert!(
627                    message.contains("AZURE_FEDERATED_TOKEN_FILE"),
628                    "error should point at the convention: {message}"
629                );
630            }
631            other => panic!("expected InvalidRequest, got {other:?}"),
632        }
633    }
634
635    #[test]
636    fn federated_token_is_read_and_trimmed() {
637        let path = std::env::temp_dir().join(format!("m365-{}.jwt", Uuid::new_v4()));
638        std::fs::write(&path, "  header.payload.signature\n").unwrap();
639
640        let token = M365Engine::read_federated_token(path.to_str().unwrap()).unwrap();
641        assert_eq!(token, "header.payload.signature");
642
643        std::fs::write(&path, "\n\n").unwrap();
644        let err = M365Engine::read_federated_token(path.to_str().unwrap()).unwrap_err();
645        assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
646
647        std::fs::remove_file(&path).unwrap();
648    }
649
650    /// `.default` grants everything the app was consented, so the `_doc` a
651    /// consumer receives must not let that pass silently.
652    #[test]
653    fn scope_description_admits_that_default_is_not_narrowing() {
654        let config = config(CredentialKind::Federated {
655            token_file: "/var/run/token".to_string(),
656        });
657        let scoped = M365Engine::scope_description(&role(), &config);
658
659        assert!(scoped.iter().any(|s| s.contains("permissions:ALL")));
660        assert!(scoped.iter().any(|s| s.contains("resource-narrowing:NONE RECORDED")));
661        assert!(scoped.contains(&format!("tenant:{}", config.tenant_id)));
662    }
663
664    #[test]
665    fn scope_description_reports_recorded_resource_narrowing() {
666        let config = config(CredentialKind::Secret {
667            client_secret: "x".to_string(),
668        });
669        let role = RoleConfig {
670            resource_hint: Some("Sites.Selected: reports-site (read)".to_string()),
671            ..role()
672        };
673        let scoped = M365Engine::scope_description(&role, &config);
674
675        assert!(
676            scoped
677                .iter()
678                .any(|s| s == "resource-narrowing:Sites.Selected: reports-site (read)")
679        );
680        assert!(!scoped.iter().any(|s| s.contains("NONE RECORDED")));
681    }
682
683    #[test]
684    fn doc_agrees_with_its_shape() {
685        let doc = M365Engine::new().doc();
686        assert_eq!(doc.shape, CredentialShape::MintExpiryOnly);
687        assert_eq!(doc.revocable, doc.shape.revocable());
688        assert!(!doc.revocable, "Graph access tokens cannot be revoked");
689        assert!(!doc.ttl.fixed, "a CTL policy can move this lifetime");
690        assert_eq!(doc.ttl.min_seconds, Some(MIN_CONFIGURABLE_TTL_SECONDS));
691    }
692
693    /// The whole point of `revoke_effect` is that it does not overstate what
694    /// happens, since three of the seven providers cannot revoke at all.
695    #[test]
696    fn doc_does_not_overstate_revocation() {
697        let doc = M365Engine::new().doc();
698        assert!(doc.revoke_effect.starts_with("nothing at the provider"));
699        assert!(doc.revoke_effect.contains("Continuous Access Evaluation"));
700    }
701
702    #[test]
703    fn federated_credential_reports_storing_no_secret() {
704        let federated = CredentialKind::Federated {
705            token_file: "/var/run/token".to_string(),
706        };
707        assert!(federated.describes_root_credential().starts_with("nothing"));
708        assert_eq!(
709            CredentialKind::Secret {
710                client_secret: "x".to_string()
711            }
712            .describes_root_credential(),
713            "a client secret"
714        );
715    }
716}