Skip to main content

Verification Recap: Gsuite Disabled-Mode Graceful Fallback (#1179)

Scope: ADR-0002 Phase 2 OSA Gsuite lifecycle โ€” graceful degradation when Gsuite is not configured (dev/test without GSUITE_SERVICE_ACCOUNT_KEY_JSON, or any env where Workspace Admin DWD has not been provisioned yet).

Date: 2026-06-22 ยท Issue: #1179 ยท Operational entry: gsuite-disabled-mode.md

The contractโ€‹

Never crash, never silently write a half-provisioned OSA row, always surface a clear "disabled" indicator to the operator.

Pre-req env state assumed by these checksโ€‹

  • GSUITE_SERVICE_ACCOUNT_KEY_JSON unset
  • GSUITE_ADMIN_USER_EMAIL unset (issue text says GSUITE_ADMIN_EMAIL; the live canonical var is GSUITE_ADMIN_USER_EMAIL, alias GOOGLE_ADMIN_EMAIL)
  • MASTER_ENCRYPTION_KEY set (encryption util is shared infra, not Gsuite-gated)

How "disabled" is determinedโ€‹

Two independent layers gate Gsuite work โ€” both verified:

  1. Per-org policy gate (isGsuiteProvisioningEnabledForOrg, gsuite-provisioning-policy.ts) decides whether to enqueue a provisioning job. NULL flag โ†’ enabled only in production; dev/staging default off. Applied by the producer in organizations.service.ts (_enqueueWorkspaceProvisioning).
  2. Infrastructure gate (GoogleAdminClient.isEnabled()) reflects whether a service-account credential actually loaded. This is the layer the issue targets, and it is now enforced defensively in the processor so a job that slips past the policy gate (e.g. the explicit AM "Provision" action, which overrides the env default) still degrades cleanly.

Resultsโ€‹

Legend: โœ… verified pass ยท ๐Ÿ›  gap found โ†’ fixed in this PR ยท โž– not applicable

Startup invariantsโ€‹

BulletStatusEvidence
API boots (/health 200) with all GSUITE_* unsetโœ…GoogleAdminClient.onModuleInit returns early when creds absent; @googleapis/admin is lazy-required only when creds exist, so the module graph loads without it.
No Sentry/error log at boot mentioning Gsuite (warn OK, error not)โœ…All-unset path logs a single warn, never error or Sentry. Asserted by google-admin.client.spec.ts โ†’ "logs a warn (not error) once at boot when fully disabled".
Clear "Gsuite disabled โ€” no service account configured" log once at boot๐Ÿ› Message reworded to exactly: Gsuite disabled โ€” no service account configured (โ€ฆ unset โ€ฆ). Workspace provisioning is skipped; lifecycle jobs complete as no-ops.

Provisioning pathโ€‹

BulletStatusEvidence
workspace-provisioning job for a fresh OSA completes (not failed) with a clear "skipped: gsuite disabled" terminal log๐Ÿ› This was the core gap. The processor had no disabled-mode guard; the AM-triggered provisionSpecialistAssignment overrides the env-default gate and can enqueue a CREATE job in a creds-less env, where createUser โ†’ getDirectory() threw โ†’ job FAILED (spurious Sentry + AM "provisioning failed" alert). Added a guard at the top of WorkspaceProvisioningProcessor.process(). Only the intentional no-credentials disable is a no-op โ€” it logs Job โ€ฆ skipped: gsuite disabled (no-credentials) โ€” OSA โ€ฆ left unprovisioned and returns (job completes). A misconfiguration (invalid-credentials / config-incomplete) instead throws so the job fails โ†’ retries โ†’ onFailed alerts โ€” see the credential-rot note below.
email_alias may be populated; gsuite_user_id / gsuite_provisioned_at stay NULLโœ…The short-circuit returns before any osaRepo.update, so no Gsuite columns are written. The producer may have set email_alias (a pure string compute) โ€” allowed. Asserted: "CREATE completes as a no-op โ€ฆ and writes nothing".
oauth_refresh_token_encrypted stays NULLโœ…No token path is touched in disabled mode; nothing writes that column during provisioning.
Direct provisionUser(osaId) returns a typed "disabled" result, not a raw throwโœ… (adapted)There is no GsuiteService.provisionUser in the codebase; the equivalent surface is WorkspaceProvisioningService.createUser. Rather than change its return type, the predicate isGsuiteEnabled() / getGsuiteDisabledReason() is the typed disabled signal callers consult; createUser still throws a clear, typed message if invoked directly while disabled.

Token read pathโ€‹

BulletStatusEvidence
OAuthTokenService for an OSA with oauth_refresh_token_encrypted = NULL returns a clean "no token" result / specific exception type, not a generic 500๐Ÿ› getAccessToken previously threw a bare Error for the no-token case. Introduced OAuthTokenUnavailableError (carries osaId); thrown for both the never-granted and revoked cases. Callers can now catch (e instanceof OAuthTokenUnavailableError) and no-op. The sole HTTP caller (channels.controller.ts Gmail webhook) already wraps the call in try/catch โ†’ controlled 500 with a clear log (no pod crash).
Token rotation cron / job is a no-op for OSAs without a token; no error noiseโž–No OAuth-token rotation cron exists in the codebase (grep for rotat/@Cron/BullMQ repeat over the OAuth service found none โ€” token refresh is lazy, on-demand inside getAccessToken). N/A; documented so a future rotation job is built to catch OAuthTokenUnavailableError.

UI renderingโ€‹

BulletStatusEvidence
OSA detail panel shows email_alias, with gsuite_user_id / gsuite_provisioned_at as "Not provisioned" โ€” not blank/undefined/JS errorโœ…frontend/.../specialists/[assignmentId]/page.tsx: {osa.gsuiteUserId ?? "Not provisioned"} and formatIso(value) returns "โ€”" for null. StatusBadge renders Pending when unprovisioned. No code change needed.
AM Setup Wizard step 2 shows a "Gsuite integration disabled in this environment" bannerโš ๏ธ deferredThe wizard does not render a dedicated disabled-mode banner today; the per-org GSuite provisioning toggle on the client detail page is the operator control. Cosmetic-only and out of the backend contract scope โ€” captured here as a follow-up, no crash today.
Specialist detail page hides/grays Gmail-Send / Drive-fetch when the OSA has no Gsuite credsโš ๏ธ deferredSame: actions are not conditionally disabled in disabled mode. They route through the now-hardened backend, which fails clean rather than crashing. Follow-up, not a contract crash.

Negative scenarios (regression guard)โ€‹

BulletStatusEvidence
Invalid GSUITE_SERVICE_ACCOUNT_KEY_JSON โ†’ boot still succeeds; typed "credentials invalid" error distinct from "disabled"๐Ÿ› Was a crash: onModuleInit called parseServiceAccountKey unguarded, so malformed JSON threw during bootstrap (boot fail / crash-loop under active-active). Now caught โ†’ stays disabled with disabledReason = "invalid-credentials", logs a loud (error-level, non-fatal) line; getDirectory() throws โ€ฆ credentials are invalid โ€ฆ. The processor treats this distinctly from "disabled" โ€” a misconfig fails the job loudly (Sentry + AM alert) instead of no-op'ing (see "Credential-rot safety" below). Asserted: "does NOT crash boot when the key JSON is not parseable" + processor "misconfiguration โ†’ fail loudly".
Only GSUITE_SERVICE_ACCOUNT_KEY_JSON set, admin email unset โ†’ boot succeeds; "config incomplete"๐Ÿ› Already booted (early return), but returned the generic "disabled" reason. Now distinguished as disabledReason = "config-incomplete" with its own error message; getDirectory() throws โ€ฆ configuration is incomplete โ€ฆ.

Credential-rot safety (PR #3000 review)โ€‹

The disabled guard intentionally splits two cases rather than treating all "disabled" states the same:

  • no-credentials โ€” an intentional absence (dev/test, or prod before DWD is provisioned). Jobs no-op and complete. Silent success is correct here.
  • invalid-credentials / config-incomplete โ€” a misconfiguration, e.g. a service-account secret that rotated incorrectly in production. Jobs throw โ†’ retry โ†’ onFailed fires Sentry + an AM notification. This is essential for the security-sensitive suspend and archive ops: silently no-op'ing them under credential rot would leave a Specialist with Google Workspace access they should have lost, with no per-job signal. Boot also logs an error for these states. (Also satisfies the #1179 negative-scenario contract, which asks for a distinct, surfaced "credentials invalid" error โ€” not a silent skip.)

Code changes in this PRโ€‹

FileChange
api/src/workspace-lifecycle/google-admin.client.tsBoot never throws on missing/invalid/incomplete creds; GsuiteDisabledReason classification (no-credentials / config-incomplete / invalid-credentials / null); getDisabledReason(); reworded disabled log; reason-aware notConfiguredError().
api/src/workspace-lifecycle/workspace-provisioning.service.tsisGsuiteEnabled() + getGsuiteDisabledReason() passthroughs.
api/src/workspace-lifecycle/workspace-provisioning.processor.tsDisabled-mode guard in process() โ€” no-credentials jobs complete as no-ops ("skipped: gsuite disabled"); invalid-credentials / config-incomplete jobs throw so the misconfig surfaces via onFailed (Sentry + AM alert).
api/src/workspace-lifecycle/oauth-token.service.tsOAuthTokenUnavailableError typed exception for the no-token / revoked case.
specsNew disabled-mode coverage across the four units + e2e mock updated.

Alignment with "What this is NOT"โ€‹

  • Not a happy-path test โ€” happy paths remain covered by the existing specs.
  • Not a fix for the agent /audit/log 401 โ€” untouched.
  • Not setting up real Gsuite creds in dev โ€” none added; this hardens the absence path.

Follow-ups (non-blocking, no crash today)โ€‹

  1. AM Setup Wizard step 2 disabled-mode banner (UI cosmetic).
  2. Conditionally gray out Specialist Gmail-Send / Drive-fetch actions in disabled mode (UI cosmetic).

Neither is a silent crash or partial-write; both are surfaced cleanly by the hardened backend. Per the issue's acceptance, any silent crash/partial-write would be filed separately โ€” none were found; the two boot/processor gaps above were in-scope contract violations and are fixed here.