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_JSONunsetGSUITE_ADMIN_USER_EMAILunset (issue text saysGSUITE_ADMIN_EMAIL; the live canonical var isGSUITE_ADMIN_USER_EMAIL, aliasGOOGLE_ADMIN_EMAIL)MASTER_ENCRYPTION_KEYset (encryption util is shared infra, not Gsuite-gated)
How "disabled" is determinedโ
Two independent layers gate Gsuite work โ both verified:
- Per-org policy gate (
isGsuiteProvisioningEnabledForOrg,gsuite-provisioning-policy.ts) decides whether to enqueue a provisioning job. NULL flag โ enabled only inproduction; dev/staging default off. Applied by the producer inorganizations.service.ts(_enqueueWorkspaceProvisioning). - 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โ
| Bullet | Status | Evidence |
|---|---|---|
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โ
| Bullet | Status | Evidence |
|---|---|---|
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โ
| Bullet | Status | Evidence |
|---|---|---|
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โ
| Bullet | Status | Evidence |
|---|---|---|
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 | โ ๏ธ deferred | The 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 | โ ๏ธ deferred | Same: 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)โ
| Bullet | Status | Evidence |
|---|---|---|
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 โonFailedfires 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โ
| File | Change |
|---|---|
api/src/workspace-lifecycle/google-admin.client.ts | Boot 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.ts | isGsuiteEnabled() + getGsuiteDisabledReason() passthroughs. |
api/src/workspace-lifecycle/workspace-provisioning.processor.ts | Disabled-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.ts | OAuthTokenUnavailableError typed exception for the no-token / revoked case. |
| specs | New 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/log401 โ untouched. - Not setting up real Gsuite creds in dev โ none added; this hardens the absence path.
Follow-ups (non-blocking, no crash today)โ
- AM Setup Wizard step 2 disabled-mode banner (UI cosmetic).
- 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.