Skip to main content

secrets_engine_gworkspace/
lib.rs

1//! Google Workspace and Drive — nothing here is mintable, so this engine's
2//! value is *custody*: the long-lived refresh token stays on the server and
3//! consumers only ever receive an access token good for about an hour. The
4//! consumer never holds the durable secret, which is most of the benefit.
5//!
6//! Domain-wide delegation is offered as a second mode, but it is a materially
7//! worse trade — see the caveats in `doc()` — and Google's own guidance is now
8//! to avoid it for new integrations.
9//!
10//! See `docs/delegation/google-workspace.md` for the mechanism and
11//! `docs/delegation/setup/google-workspace.md` for the operator walkthrough.
12
13use async_trait::async_trait;
14use chrono::{DateTime, Utc};
15use secrets_core::engine::{
16    CredentialShape, EngineDoc, EngineError, EngineResult, GeneratedCredential, PathDoc,
17    SecretsEngine, TtlDoc,
18};
19use secrets_core::lease::Lease;
20use secrets_core::mount::ConfigRoleStore;
21use secrets_core::storage::StorageBackend;
22use serde::{Deserialize, Serialize};
23use serde_json::json;
24use uuid::Uuid;
25
26const STORE: ConfigRoleStore = ConfigRoleStore::new("gworkspace/config/", "gworkspace/roles/");
27const MOUNT: &str = "gworkspace/creds/";
28const TOKEN_ENDPOINT: &str = "https://oauth2.googleapis.com/token";
29const REVOKE_ENDPOINT: &str = "https://oauth2.googleapis.com/revoke";
30const JWT_BEARER_GRANT: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
31
32/// Google accepts a delegation assertion with `exp` up to an hour out, but the
33/// assertion is exchanged immediately — a short window limits the damage if one
34/// ever reaches a log.
35const ASSERTION_TTL_SECONDS: i64 = 600;
36
37/// Google's access tokens are ~1 hour and it tells us so in `expires_in`; this
38/// is only the fallback for a response that omits it.
39const FALLBACK_ACCESS_TOKEN_TTL_SECONDS: i64 = 3600;
40
41/// One authorised account. Every field here is durable secret material, which
42/// is why `ConfigRoleStore` makes config write-only: a read reports existence
43/// and nothing else.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct GworkspaceConfig {
46    /// OAuth client credentials from the Google Cloud console. Required for
47    /// `refresh_token` mode, unused by domain-wide delegation.
48    #[serde(default)]
49    pub client_id: String,
50    #[serde(default)]
51    pub client_secret: String,
52    /// The long-lived half, obtained once through an interactive consent flow.
53    /// This is the secret consumers must never see.
54    #[serde(default)]
55    pub refresh_token: Option<String>,
56    /// Service-account key JSON, for domain-wide delegation only.
57    #[serde(default)]
58    pub service_account_key_json: Option<String>,
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
62#[serde(rename_all = "snake_case")]
63pub enum Mode {
64    /// Broker an access token from a stored refresh token. The recommended mode.
65    #[default]
66    RefreshToken,
67    /// Impersonate a named user with a service account the domain admin has
68    /// authorised. Convenient, and far more dangerous.
69    DomainWideDelegation,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct RoleConfig {
74    /// Which `gworkspace/config/{name}` document to authenticate with.
75    pub target: String,
76    #[serde(default)]
77    pub mode: Mode,
78    /// OAuth scopes, e.g. `https://www.googleapis.com/auth/drive.readonly`.
79    /// Prefer the narrowest scope that works: this is the only scoping Google
80    /// offers at the credential level.
81    #[serde(default)]
82    pub scopes: Vec<String>,
83    /// The user to impersonate. Required for `domain_wide_delegation`,
84    /// meaningless otherwise.
85    #[serde(default)]
86    pub subject: Option<String>,
87}
88
89/// The fields we need out of a service-account key file.
90#[derive(Debug, Deserialize)]
91struct ServiceAccountKey {
92    client_email: String,
93    private_key: String,
94}
95
96#[derive(Debug, Deserialize)]
97struct TokenResponse {
98    access_token: String,
99    #[serde(default)]
100    expires_in: Option<i64>,
101    /// Google does not rotate refresh tokens on every use, but it may reissue
102    /// one near end of life. Dropping it silently would strand the account.
103    #[serde(default)]
104    refresh_token: Option<String>,
105    #[serde(default)]
106    scope: Option<String>,
107}
108
109#[derive(Debug, Serialize, PartialEq, Eq)]
110struct AssertionClaims {
111    iss: String,
112    sub: String,
113    scope: String,
114    aud: String,
115    iat: i64,
116    exp: i64,
117}
118
119#[derive(Default)]
120pub struct GworkspaceEngine {
121    http: reqwest::Client,
122}
123
124impl GworkspaceEngine {
125    pub fn new() -> Self {
126        Self {
127            http: reqwest::Client::new(),
128        }
129    }
130
131    fn require_refresh_token(config: &GworkspaceConfig) -> EngineResult<&str> {
132        config.refresh_token.as_deref().filter(|t| !t.is_empty()).ok_or_else(|| {
133            EngineError::InvalidRequest(
134                "this gworkspace/config has no refresh_token. Complete the consent \
135                 flow once and POST the resulting refresh token to the config path."
136                    .into(),
137            )
138        })
139    }
140
141    fn service_account(config: &GworkspaceConfig) -> EngineResult<ServiceAccountKey> {
142        let raw = config
143            .service_account_key_json
144            .as_deref()
145            .filter(|k| !k.is_empty())
146            .ok_or_else(|| {
147                EngineError::InvalidRequest(
148                    "domain_wide_delegation needs service_account_key_json in \
149                     gworkspace/config"
150                        .into(),
151                )
152            })?;
153        serde_json::from_str(raw).map_err(|e| {
154            EngineError::InvalidRequest(format!("service_account_key_json is not a valid key: {e}"))
155        })
156    }
157
158    /// Builds the delegation assertion's claims. The `sub` claim is what turns a
159    /// service-account token into "act as this user", so an unset subject would
160    /// silently produce a credential for the service account itself rather than
161    /// the intended person — refuse instead.
162    fn assertion_claims(
163        service_account_email: &str,
164        role: &RoleConfig,
165        now: DateTime<Utc>,
166    ) -> EngineResult<AssertionClaims> {
167        let subject = role.subject.as_deref().filter(|s| !s.is_empty()).ok_or_else(|| {
168            EngineError::InvalidRequest(
169                "domain_wide_delegation requires `subject`: the email address of the \
170                 user to impersonate"
171                    .into(),
172            )
173        })?;
174        Ok(AssertionClaims {
175            iss: service_account_email.to_string(),
176            sub: subject.to_string(),
177            scope: role.scopes.join(" "),
178            aud: TOKEN_ENDPOINT.to_string(),
179            iat: now.timestamp(),
180            exp: now.timestamp() + ASSERTION_TTL_SECONDS,
181        })
182    }
183
184    fn sign_assertion(key: &ServiceAccountKey, claims: &AssertionClaims) -> EngineResult<String> {
185        let encoding_key = jsonwebtoken::EncodingKey::from_rsa_pem(key.private_key.as_bytes())
186            .map_err(|e| {
187                EngineError::InvalidRequest(format!(
188                    "service account private_key is not a valid RSA PEM: {e}"
189                ))
190            })?;
191        jsonwebtoken::encode(
192            &jsonwebtoken::Header::new(jsonwebtoken::Algorithm::RS256),
193            claims,
194            &encoding_key,
195        )
196        .map_err(|e| EngineError::Other(format!("failed to sign delegation assertion: {e}")))
197    }
198
199    async fn post_token_form(&self, form: &[(&str, &str)]) -> EngineResult<TokenResponse> {
200        let response = self
201            .http
202            .post(TOKEN_ENDPOINT)
203            .form(form)
204            .send()
205            .await
206            .map_err(|e| EngineError::Provider(format!("Google token request failed: {e}")))?;
207
208        let status = response.status();
209        let text = response.text().await.unwrap_or_default();
210        if !status.is_success() {
211            // `invalid_grant` is terminal, not transient: the authorisation is
212            // gone and a human has to consent again. Callers that retry it will
213            // retry forever, so name it in the error.
214            if text.contains("invalid_grant") {
215                return Err(EngineError::Provider(format!(
216                    "Google returned invalid_grant — the authorisation is gone \
217                     (revoked, expired, or the consent screen is still in Testing). \
218                     Re-consent is required; retrying will not help. Body: {text}"
219                )));
220            }
221            return Err(EngineError::Provider(format!(
222                "Google returned {status}: {text}"
223            )));
224        }
225        serde_json::from_str(&text)
226            .map_err(|e| EngineError::Provider(format!("unexpected Google response: {e}")))
227    }
228
229    fn scope_description(role: &RoleConfig) -> Vec<String> {
230        let mut scoped = Vec::new();
231        match role.mode {
232            Mode::RefreshToken => {
233                scoped.push("identity:the user who granted consent".to_string());
234            }
235            Mode::DomainWideDelegation => scoped.push(format!(
236                "identity:{} (impersonated via domain-wide delegation)",
237                role.subject.as_deref().unwrap_or("UNSET")
238            )),
239        }
240        if role.scopes.is_empty() {
241            scoped.push("scopes:NONE — Google will reject the exchange".to_string());
242        } else {
243            scoped.extend(role.scopes.iter().map(|s| format!("scope:{s}")));
244        }
245        scoped
246    }
247
248    /// Persists a reissued refresh token. Uses the store's own write path so the
249    /// document stays in the same shape the operator POSTed.
250    async fn persist_rotated_refresh_token(
251        storage: &dyn StorageBackend,
252        target: &str,
253        config: &GworkspaceConfig,
254        new_refresh_token: &str,
255    ) -> EngineResult<()> {
256        let mut updated = config.clone();
257        updated.refresh_token = Some(new_refresh_token.to_string());
258        let value = serde_json::to_value(&updated).map_err(|e| EngineError::Other(e.to_string()))?;
259        STORE
260            .handle_write::<GworkspaceConfig, RoleConfig>(
261                storage,
262                &format!("config/{target}"),
263                value,
264            )
265            .await
266    }
267}
268
269#[async_trait]
270impl SecretsEngine for GworkspaceEngine {
271    fn doc(&self) -> EngineDoc {
272        EngineDoc {
273            provider: "Google Workspace".to_string(),
274            mechanism: "brokered OAuth access tokens: the server holds the \
275                        long-lived refresh token and exchanges it for a ~1 hour \
276                        access token per request, so the consumer never sees the \
277                        durable secret. Domain-wide delegation is available as a \
278                        second mode."
279                .to_string(),
280            shape: CredentialShape::RefreshBroker,
281            // Deliberately contradicts `shape.revocable()`. Shape C means the
282            // *durable* half is revocable, which is true here — but unlike
283            // Dropbox, revoking Google's refresh token does nothing to an access
284            // token already issued. The field that a consumer reads must
285            // describe the credential it was actually handed.
286            revocable: false,
287            revoke_effect: "nothing at the provider. Google cannot invalidate an \
288                            individual access token, so a revoked lease only \
289                            deletes our record and stops renewal — the token keeps \
290                            working for the rest of its hour. We deliberately do \
291                            NOT call Google's /revoke on lease expiry: that would \
292                            destroy the whole authorisation and require a human to \
293                            re-consent. To do that on purpose, DELETE the config \
294                            document and POST the refresh token to \
295                            https://oauth2.googleapis.com/revoke."
296                .to_string(),
297            ttl: TtlDoc::range(
298                600,
299                3600,
300                "Google decides, and reports it in expires_in — about an hour in \
301                 practice. The lease is set from that value rather than from a \
302                 requested TTL, so it can never outlive the token.",
303            ),
304            scoping: "OAuth scopes only, chosen per role. There is no per-file or \
305                      per-folder scoping in the credential: narrowness comes from \
306                      picking drive.readonly or drive.metadata.readonly over drive, \
307                      and from Drive ACLs on the content itself."
308                .to_string(),
309            root_credential: "a refresh token per authorised account (refresh_token \
310                              mode), or a service-account key authorised for \
311                              domain-wide delegation. Both live at \
312                              gworkspace/config/{target} and are never readable back."
313                .to_string(),
314            paths: vec![
315                PathDoc::new(
316                    "gworkspace/config/{target}",
317                    &["POST", "GET", "DELETE"],
318                    "sudo",
319                    "register one account's OAuth client, refresh token or \
320                     service-account key. GET reports only whether it is \
321                     configured — no secret is ever returned.",
322                ),
323                PathDoc::new(
324                    "gworkspace/roles/{role}",
325                    &["POST", "GET", "DELETE"],
326                    "create / read / sudo",
327                    "define one consumer's mode, scopes and impersonated subject",
328                ),
329                PathDoc::new(
330                    "gworkspace/creds/{role}",
331                    &["GET"],
332                    "read",
333                    "exchange the stored grant for a short-lived access token and \
334                     open a lease",
335                ),
336                PathDoc::new("gworkspace/help", &["GET"], "authenticated", "this document"),
337            ],
338            docs_url: Some("docs/delegation/google-workspace.md".to_string()),
339            caveats: vec![
340                "A refresh token expires after SEVEN DAYS while the OAuth consent \
341                 screen's publishing status is still 'Testing'. This catches almost \
342                 everyone once — publish the app."
343                    .to_string(),
344                "Refresh tokens also die after six months of non-use, when the user \
345                 revokes access, and in some cases on password change."
346                    .to_string(),
347                "`invalid_grant` on refresh means the authorisation is gone and a \
348                 human must re-consent. It is never a transient error, so never \
349                 retry it as one."
350                    .to_string(),
351                "Domain-wide delegation lets one service account impersonate ANY \
352                 user in the domain for the granted scopes, with no user able to \
353                 see or revoke it. Google's own guidance is to avoid it for new \
354                 integrations — prefer per-user consent, i.e. refresh_token mode."
355                    .to_string(),
356                "Shared drives are governed by ACL membership, not by scopes: the \
357                 consenting user or impersonated subject must be an explicit member \
358                 of the shared drive, and queries need corpora=drive with driveId."
359                    .to_string(),
360                "Restricted Gmail and Drive scopes require app verification, and the \
361                 most sensitive ones a third-party security assessment."
362                    .to_string(),
363            ],
364        }
365    }
366
367    async fn read(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<serde_json::Value> {
368        STORE.handle_read::<RoleConfig>(storage, path).await
369    }
370
371    async fn write(
372        &self,
373        storage: &dyn StorageBackend,
374        path: &str,
375        data: serde_json::Value,
376    ) -> EngineResult<()> {
377        STORE
378            .handle_write::<GworkspaceConfig, RoleConfig>(storage, path, data)
379            .await
380    }
381
382    async fn delete(&self, storage: &dyn StorageBackend, path: &str) -> EngineResult<()> {
383        STORE.handle_delete(storage, path).await
384    }
385
386    async fn list(&self, storage: &dyn StorageBackend, prefix: &str) -> EngineResult<Vec<String>> {
387        STORE.handle_list(storage, prefix).await
388    }
389
390    async fn generate(
391        &self,
392        storage: &dyn StorageBackend,
393        role_name: &str,
394    ) -> EngineResult<GeneratedCredential> {
395        let role: RoleConfig = STORE.require_role(storage, role_name).await?;
396        let config: GworkspaceConfig = STORE.require_config(storage, &role.target).await?;
397
398        let now = Utc::now();
399        let token = match role.mode {
400            Mode::RefreshToken => {
401                let refresh_token = Self::require_refresh_token(&config)?;
402                self.post_token_form(&[
403                    ("grant_type", "refresh_token"),
404                    ("refresh_token", refresh_token),
405                    ("client_id", &config.client_id),
406                    ("client_secret", &config.client_secret),
407                ])
408                .await?
409            }
410            Mode::DomainWideDelegation => {
411                let key = Self::service_account(&config)?;
412                let claims = Self::assertion_claims(&key.client_email, &role, now)?;
413                let assertion = Self::sign_assertion(&key, &claims)?;
414                self.post_token_form(&[
415                    ("grant_type", JWT_BEARER_GRANT),
416                    ("assertion", &assertion),
417                ])
418                .await?
419            }
420        };
421
422        if let Some(rotated) = token.refresh_token.as_deref()
423            && Some(rotated) != config.refresh_token.as_deref()
424        {
425            Self::persist_rotated_refresh_token(storage, &role.target, &config, rotated).await?;
426            tracing::info!(target = %role.target, "stored a reissued Google refresh token");
427        }
428
429        let ttl_seconds = token.expires_in.unwrap_or(FALLBACK_ACCESS_TOKEN_TTL_SECONDS);
430        let lease = Lease {
431            id: Uuid::new_v4(),
432            // Set by the HTTP handler, which knows the requesting token.
433            token_id_hash: String::new(),
434            engine_mount: MOUNT.to_string(),
435            // The access token is deliberately absent: it cannot be revoked, so
436            // keeping a copy would widen exposure for no operational gain.
437            internal_data: json!({
438                "role": role_name,
439                "target": role.target,
440                "mode": role.mode,
441            }),
442            issued_at: now,
443            expires_at: now + chrono::Duration::seconds(ttl_seconds),
444        };
445
446        let credential = GeneratedCredential::new(
447            json!({
448                "access_token": token.access_token,
449                "token_type": "Bearer",
450                "expires_in": ttl_seconds,
451                "granted_scope": token.scope,
452            }),
453            lease,
454            Self::scope_description(&role),
455        );
456
457        Ok(match role.mode {
458            // Nothing durable is brokered here — the service-account key is the
459            // secret, and it can reach every user in the domain.
460            Mode::DomainWideDelegation => credential.with_shape(
461                CredentialShape::MintExpiryOnly,
462                "nothing. This token was minted by impersonating a user through \
463                 domain-wide delegation, and Google cannot invalidate it. It stops \
464                 working when it expires. To cut off delegation entirely, remove \
465                 the service account's client ID from the Admin console.",
466            ),
467            Mode::RefreshToken => credential,
468        })
469    }
470
471    async fn revoke(&self, _storage: &dyn StorageBackend, lease: &Lease) -> EngineResult<()> {
472        // A deliberate refusal, not an omission. Google offers no way to kill a
473        // single access token, and the one endpoint that does work
474        // (REVOKE_ENDPOINT, on the refresh token) would destroy the entire
475        // authorisation and require a human to re-consent — a catastrophic
476        // response to a lease simply reaching its expiry. Returning Ok lets the
477        // reaper clear its record, which is all that is actually possible.
478        tracing::warn!(
479            lease = %lease.id,
480            "gworkspace lease revoked locally only: Google cannot invalidate an \
481             issued access token, so it remains valid until it expires. To revoke \
482             the underlying authorisation on purpose, DELETE the config document \
483             and POST its refresh token to {REVOKE_ENDPOINT}"
484        );
485        Ok(())
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492
493    fn role(mode: Mode, subject: Option<&str>, scopes: &[&str]) -> RoleConfig {
494        RoleConfig {
495            target: "acme".to_string(),
496            mode,
497            scopes: scopes.iter().map(|s| s.to_string()).collect(),
498            subject: subject.map(|s| s.to_string()),
499        }
500    }
501
502    #[test]
503    fn assertion_claims_impersonate_the_subject() {
504        let now = Utc::now();
505        let claims = GworkspaceEngine::assertion_claims(
506            "svc@acme.iam.gserviceaccount.com",
507            &role(
508                Mode::DomainWideDelegation,
509                Some("alice@acme.com"),
510                &["https://www.googleapis.com/auth/drive.readonly"],
511            ),
512            now,
513        )
514        .expect("claims");
515
516        assert_eq!(claims.iss, "svc@acme.iam.gserviceaccount.com");
517        // `sub` is the whole point of delegation — it is what makes the token
518        // act as the user rather than as the service account.
519        assert_eq!(claims.sub, "alice@acme.com");
520        assert_eq!(claims.aud, TOKEN_ENDPOINT);
521        assert_eq!(claims.scope, "https://www.googleapis.com/auth/drive.readonly");
522        assert!(claims.exp - claims.iat <= 3600, "Google caps the assertion at one hour");
523    }
524
525    #[test]
526    fn assertion_claims_join_multiple_scopes_with_spaces() {
527        let claims = GworkspaceEngine::assertion_claims(
528            "svc@acme.iam.gserviceaccount.com",
529            &role(Mode::DomainWideDelegation, Some("alice@acme.com"), &["a", "b"]),
530            Utc::now(),
531        )
532        .expect("claims");
533        assert_eq!(claims.scope, "a b");
534    }
535
536    /// Without `sub` the exchange would quietly yield a token for the service
537    /// account itself, which is a different and broader identity than intended.
538    #[test]
539    fn domain_wide_delegation_requires_a_subject() {
540        for subject in [None, Some("")] {
541            let err = GworkspaceEngine::assertion_claims(
542                "svc@acme.iam.gserviceaccount.com",
543                &role(Mode::DomainWideDelegation, subject, &["a"]),
544                Utc::now(),
545            )
546            .expect_err("a missing subject must be refused");
547            assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
548            assert!(err.to_string().contains("subject"));
549        }
550    }
551
552    #[test]
553    fn domain_wide_delegation_requires_a_service_account_key() {
554        let config = GworkspaceConfig {
555            client_id: String::new(),
556            client_secret: String::new(),
557            refresh_token: None,
558            service_account_key_json: None,
559        };
560        let err = GworkspaceEngine::service_account(&config).expect_err("must be refused");
561        assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
562    }
563
564    #[test]
565    fn refresh_token_mode_requires_a_stored_refresh_token() {
566        for stored in [None, Some(String::new())] {
567            let config = GworkspaceConfig {
568                client_id: "id".to_string(),
569                client_secret: "secret".to_string(),
570                refresh_token: stored,
571                service_account_key_json: None,
572            };
573            let err = GworkspaceEngine::require_refresh_token(&config)
574                .expect_err("must be refused");
575            assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
576        }
577    }
578
579    #[test]
580    fn rejects_a_service_account_key_that_is_not_a_pem() {
581        let key = ServiceAccountKey {
582            client_email: "svc@acme.iam.gserviceaccount.com".to_string(),
583            private_key: "not a pem".to_string(),
584        };
585        let claims = GworkspaceEngine::assertion_claims(
586            &key.client_email,
587            &role(Mode::DomainWideDelegation, Some("alice@acme.com"), &["a"]),
588            Utc::now(),
589        )
590        .expect("claims");
591        let err = GworkspaceEngine::sign_assertion(&key, &claims).expect_err("must be refused");
592        assert!(matches!(err, EngineError::InvalidRequest(_)), "got {err:?}");
593    }
594
595    #[test]
596    fn scope_description_names_the_impersonated_user() {
597        let scoped = GworkspaceEngine::scope_description(&role(
598            Mode::DomainWideDelegation,
599            Some("alice@acme.com"),
600            &["https://www.googleapis.com/auth/drive.readonly"],
601        ));
602        assert!(scoped.iter().any(|s| s.contains("alice@acme.com")));
603        assert!(
604            scoped.contains(&"scope:https://www.googleapis.com/auth/drive.readonly".to_string())
605        );
606    }
607
608    #[test]
609    fn scope_description_flags_a_role_with_no_scopes() {
610        let scoped = GworkspaceEngine::scope_description(&role(Mode::RefreshToken, None, &[]));
611        assert!(scoped.iter().any(|s| s.contains("scopes:NONE")));
612    }
613
614    #[test]
615    fn mode_defaults_to_the_safer_refresh_token_flow() {
616        let role: RoleConfig =
617            serde_json::from_value(json!({ "target": "acme" })).expect("minimal role");
618        assert_eq!(role.mode, Mode::RefreshToken);
619    }
620
621    /// The headline shape is C, and an issued Google access token cannot be
622    /// recalled — so `revocable` is false and must agree with the shape, which
623    /// answers the narrow question "does revoking the lease kill what the
624    /// consumer holds?".
625    #[test]
626    fn doc_reports_the_truth_about_revocation() {
627        let doc = GworkspaceEngine::new().doc();
628        assert_eq!(doc.shape, CredentialShape::RefreshBroker);
629        assert!(!doc.revocable);
630        assert!(!doc.shape.revocable());
631        assert!(doc.revoke_effect.contains("nothing at the provider"));
632    }
633}