From b1c5e7c1df9098c0a938b908c1048070515739a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 17:33:57 +0000 Subject: [PATCH] Add Google Drive integration (connect, browse, import) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a Macro user connect their Google Drive account and import selected folders/files into Macro as Projects and Documents (one-way import, no sync) — matching the "import folders, but no sync; pick which folder trees" feedback. Backend - New `google_drive` crate (hexagonal domain/ports/service/outbound/inbound): Drive v3 REST client, redis-cached access-token client (via authentication_service), a `google_drive_links` Postgres repo, and an import orchestration service that walks the selected tree via an explicit work-stack (parents before children) over a `DriveImportSink` port. - authentication_service: `/link/google-drive` init/finalize/status/delete endpoints (reusing the shared Google OAuth callback) and an `/internal/google_drive_access_token` endpoint. - document_storage_service: `DriveImportSink` adapter that creates Projects + Documents (reusing the existing presigned-upload + content pipeline) and records each Drive->Macro mapping in `foreign_entity`; wires `/google-drive/{files,import,connection}` under the authenticated router. - authentication_service_client: `get_google_drive_access_token`. - macro_db_client: `google_drive_links` migration. - infra: `google_drive` FusionAuth IdP (Drive scopes, shared Google client). Frontend (SolidJS) - service-clients (auth + storage) and `@queries/drive` hooks for connect/status/browse/import. - "Google Drive" row in Account settings (Enable/Disable/Reconnect + Import), an import dialog (folder navigator + multi-select), the OAuth callback route, and a Drive icon. Notes - Google-native docs (Docs/Sheets/Slides) are exported to PDF for a robust v1 (they land directly in object storage); editable Office exports are a follow-up. - The new `google_drive_links` SQLx queries require `just prepare_db` against a live Postgres to build offline (no DB was available in this environment, so the DB-touching crates and the dss/auth-service builds were not compiled here; the `google_drive` crate's non-DB surface, the auth client, and the frontend type-check all pass). https://claude.ai/code/session_01QwvJehxEZgSEH8RxA3Xecj --- .../fusionauth-instance/Pulumi.dev.yaml | 1 + .../fusionauth-instance/Pulumi.prod.yaml | 1 + infra/stacks/fusionauth-instance/index.ts | 42 +++ .../app/component/GoogleDriveLinkCallback.tsx | 28 ++ js/app/packages/app/component/Root.tsx | 5 + .../app/component/settings/Account.tsx | 111 ++++++ .../settings/GoogleDriveImportDialog.tsx | 192 ++++++++++ js/app/packages/icon/mcp-google-drive.svg | 8 + js/app/packages/queries/drive/folders.ts | 27 ++ js/app/packages/queries/drive/import.ts | 16 + js/app/packages/queries/drive/index.ts | 10 + js/app/packages/queries/drive/keys.ts | 8 + js/app/packages/queries/drive/link.ts | 63 ++++ .../service-clients/service-auth/client.ts | 67 ++++ .../service-clients/service-storage/client.ts | 60 +++ rust/cloud-storage/Cargo.lock | 32 ++ rust/cloud-storage/Cargo.toml | 1 + .../authentication_service/Cargo.toml | 3 + .../src/api/internal/google_access_token.rs | 14 +- .../src/api/internal/mod.rs | 4 + .../src/api/link/google_drive.rs | 336 +++++++++++++++++ .../src/api/link/mod.rs | 17 + .../authentication_service/src/api/swagger.rs | 10 + .../src/google_drive_access_token.rs | 66 ++++ .../authentication_service_client/src/lib.rs | 1 + .../document_storage_service/Cargo.toml | 3 + .../src/api/context.rs | 19 + .../document_storage_service/src/api/mod.rs | 8 + .../document_storage_service/src/main.rs | 28 ++ .../src/service/google_drive_import_sink.rs | 267 +++++++++++++ .../src/service/mod.rs | 1 + rust/cloud-storage/google_drive/Cargo.toml | 56 +++ rust/cloud-storage/google_drive/src/domain.rs | 5 + .../google_drive/src/domain/models.rs | 27 ++ .../google_drive/src/domain/models/drive.rs | 108 ++++++ .../google_drive/src/domain/models/error.rs | 21 ++ .../google_drive/src/domain/models/import.rs | 95 +++++ .../google_drive/src/domain/models/link.rs | 27 ++ .../google_drive/src/domain/ports.rs | 154 ++++++++ .../google_drive/src/domain/service.rs | 284 ++++++++++++++ .../google_drive/src/domain/service/test.rs | 355 ++++++++++++++++++ .../cloud-storage/google_drive/src/inbound.rs | 3 + .../src/inbound/google_drive_router.rs | 189 ++++++++++ rust/cloud-storage/google_drive/src/lib.rs | 31 ++ .../google_drive/src/outbound.rs | 16 + .../src/outbound/access_token_client.rs | 82 ++++ .../src/outbound/drive_api_client.rs | 168 +++++++++ .../src/outbound/pg_google_drive_link_repo.rs | 75 ++++ ...260609170742_create_google_drive_links.sql | 19 + 49 files changed, 3163 insertions(+), 1 deletion(-) create mode 100644 js/app/packages/app/component/GoogleDriveLinkCallback.tsx create mode 100644 js/app/packages/app/component/settings/GoogleDriveImportDialog.tsx create mode 100644 js/app/packages/icon/mcp-google-drive.svg create mode 100644 js/app/packages/queries/drive/folders.ts create mode 100644 js/app/packages/queries/drive/import.ts create mode 100644 js/app/packages/queries/drive/index.ts create mode 100644 js/app/packages/queries/drive/keys.ts create mode 100644 js/app/packages/queries/drive/link.ts create mode 100644 rust/cloud-storage/authentication_service/src/api/link/google_drive.rs create mode 100644 rust/cloud-storage/authentication_service_client/src/google_drive_access_token.rs create mode 100644 rust/cloud-storage/document_storage_service/src/service/google_drive_import_sink.rs create mode 100644 rust/cloud-storage/google_drive/Cargo.toml create mode 100644 rust/cloud-storage/google_drive/src/domain.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/models.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/models/drive.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/models/error.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/models/import.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/models/link.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/ports.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/service.rs create mode 100644 rust/cloud-storage/google_drive/src/domain/service/test.rs create mode 100644 rust/cloud-storage/google_drive/src/inbound.rs create mode 100644 rust/cloud-storage/google_drive/src/inbound/google_drive_router.rs create mode 100644 rust/cloud-storage/google_drive/src/lib.rs create mode 100644 rust/cloud-storage/google_drive/src/outbound.rs create mode 100644 rust/cloud-storage/google_drive/src/outbound/access_token_client.rs create mode 100644 rust/cloud-storage/google_drive/src/outbound/drive_api_client.rs create mode 100644 rust/cloud-storage/google_drive/src/outbound/pg_google_drive_link_repo.rs create mode 100644 rust/cloud-storage/macro_db_client/migrations/20260609170742_create_google_drive_links.sql diff --git a/infra/stacks/fusionauth-instance/Pulumi.dev.yaml b/infra/stacks/fusionauth-instance/Pulumi.dev.yaml index 036a358861..044e517091 100644 --- a/infra/stacks/fusionauth-instance/Pulumi.dev.yaml +++ b/infra/stacks/fusionauth-instance/Pulumi.dev.yaml @@ -13,6 +13,7 @@ config: fusionauth-instance:default-from-email: authdev@macro.com fusionauth-instance:fusionauth-google-idp-id: 17dc55db-c78e-4b19-940b-2e847479a08f fusionauth-instance:fusionauth-google-gmail-idp-id: b99932ac-bce5-4f1d-99a1-e64760aae811 + fusionauth-instance:fusionauth-google-drive-idp-id: c6dd2af6-228e-4925-862c-6ff19dfdc177 fusionauth-instance:google-client-id-secret-key: google-client-id-dev fusionauth-instance:google-client-secret-secret-key: google-client-secret-dev fusionauth:host: https://fusionauth-dev.macro.com diff --git a/infra/stacks/fusionauth-instance/Pulumi.prod.yaml b/infra/stacks/fusionauth-instance/Pulumi.prod.yaml index ded7fa2377..bce06ab0fb 100644 --- a/infra/stacks/fusionauth-instance/Pulumi.prod.yaml +++ b/infra/stacks/fusionauth-instance/Pulumi.prod.yaml @@ -13,6 +13,7 @@ config: fusionauth-instance:default-from-email: auth@macro.com fusionauth-instance:fusionauth-google-idp-id: 3707ffc1-c785-4537-a537-10c9ba363e1e fusionauth-instance:fusionauth-google-gmail-idp-id: 33fbd135-08c1-4975-8987-cf2456752c2d + fusionauth-instance:fusionauth-google-drive-idp-id: b79d07f8-ea16-4ab8-ba64-c5f80515c4d1 fusionauth-instance:google-client-id-secret-key: google-client-id-prod fusionauth-instance:google-client-secret-secret-key: google-client-secret-prod fusionauth:host: https://auth.macro.com diff --git a/infra/stacks/fusionauth-instance/index.ts b/infra/stacks/fusionauth-instance/index.ts index f6c4bed79c..5cd0c6b45a 100644 --- a/infra/stacks/fusionauth-instance/index.ts +++ b/infra/stacks/fusionauth-instance/index.ts @@ -338,6 +338,12 @@ const GOOGLE_GMAIL_IDP_ID = ? undefined : config.require('fusionauth-google-gmail-idp-id'); +// Google drive identity provider id +const GOOGLE_DRIVE_IDP_ID = + stack === 'local' + ? undefined + : config.require('fusionauth-google-drive-idp-id'); + const GOOGLE_CLIENT_ID = aws.secretsmanager .getSecretVersionOutput({ secretId: config.require('google-client-id-secret-key'), @@ -420,3 +426,39 @@ new FusionAuthIdpOpenIdConnect( protect: stack !== 'local', } ); + +// The google drive identity provider — reuses the shared Google OAuth client +// but requests Drive scopes, so a user can connect Drive independently of Gmail. +new FusionAuthIdpOpenIdConnect( + 'google-drive-idp', + { + enabled: true, + idpId: GOOGLE_DRIVE_IDP_ID, + name: 'google_drive', + oauth2ClientId: GOOGLE_CLIENT_ID, + oauth2ClientSecret: GOOGLE_CLIENT_SECRET, + oauth2ClientAuthenticationMethod: 'client_secret_basic', + oauth2AuthorizationEndpoint: + 'https://accounts.google.com/o/oauth2/v2/auth?prompt=consent&access_type=offline', + oauth2TokenEndpoint: 'https://oauth2.googleapis.com/token', + oauth2UserInfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo', + buttonText: 'GoogleDrive', + oauth2Scope: 'openid profile email https://www.googleapis.com/auth/drive', + oauth2UniqueIdClaim: 'sub', + linkingStrategy: 'LinkByEmail', + debug: stack !== 'prod', + lambdaReconcileId: reconcileSecondaryIdpLinkLambdaId, + applicationConfigurations: [ + { + applicationId: pulumi.interpolate`${macroApplication.oauthConfiguration.clientId}`, + enabled: true, + createRegistration: true, + }, + ], + }, + { + dependsOn: macroApplication, + provider: fusionAuthProvider, + protect: stack !== 'local', + } +); diff --git a/js/app/packages/app/component/GoogleDriveLinkCallback.tsx b/js/app/packages/app/component/GoogleDriveLinkCallback.tsx new file mode 100644 index 0000000000..8aa8a60de2 --- /dev/null +++ b/js/app/packages/app/component/GoogleDriveLinkCallback.tsx @@ -0,0 +1,28 @@ +import { LoadingBlock } from '@core/component/LoadingBlock'; +import { toast } from '@core/component/Toast/Toast'; +import { useFinalizeGoogleDriveLink } from '@queries/drive'; +import { useNavigate } from '@solidjs/router'; +import { onMount } from 'solid-js'; + +/** + * Landing page for the Google Drive OAuth redirect. The shared + * `/oauth2/google/callback` has already created the FusionAuth identity-provider + * link; here we call `finalize` (authenticated) to persist the + * `google_drive_links` row, then return to the app. + */ +export function GoogleDriveLinkCallback(props: { successPath: string }) { + const navigate = useNavigate(); + const finalize = useFinalizeGoogleDriveLink(); + + onMount(async () => { + try { + await finalize.mutateAsync(); + toast.success('Google Drive connected'); + } catch { + toast.failure('Failed to connect Google Drive'); + } + navigate(props.successPath, { replace: true }); + }); + + return ; +} diff --git a/js/app/packages/app/component/Root.tsx b/js/app/packages/app/component/Root.tsx index 54ff0b3cf0..6171b12736 100644 --- a/js/app/packages/app/component/Root.tsx +++ b/js/app/packages/app/component/Root.tsx @@ -91,6 +91,7 @@ import { setCookie } from './auth/Shared'; import { Signup } from './auth/Signup'; import { makeEmailAuthComponents } from './EmailAuth'; import { GlobalAppStateProvider } from './GlobalAppState'; +import { GoogleDriveLinkCallback } from './GoogleDriveLinkCallback'; import { InteractiveOnboardingModal } from './interactive-onboarding/InteractiveOnboardingModal'; import { Layout } from './Layout'; import { SearchProvider } from './next-soup/search-context'; @@ -306,6 +307,10 @@ const ROUTES: RouteDefinition[] = [ path: LINK_CALLBACK_PATH, component: EmailLinkCallback, }, + { + path: '/drive-link-callback', + component: () => , + }, { path: '/login/popup/success', component: () => { diff --git a/js/app/packages/app/component/settings/Account.tsx b/js/app/packages/app/component/settings/Account.tsx index 32fd12aa15..f258c03209 100644 --- a/js/app/packages/app/component/settings/Account.tsx +++ b/js/app/packages/app/component/settings/Account.tsx @@ -59,6 +59,8 @@ import PaywallTeamOwnerView from '../paywall/PaywallTeamOwnerView'; import { ROUTER_BASE_CONCAT } from '@app/constants/routerBase'; import { useEmailLinks, useEmailLinksStatus } from '@core/email-link'; import { useInitGmailLink } from '@queries/auth'; +import GoogleDriveIcon from '@icon/mcp-google-drive.svg'; +import { GoogleDriveImportDialog } from './GoogleDriveImportDialog'; import { useRemoveInboxMutation } from '@queries/email/link'; import { type SupportedNotificationSettings, @@ -421,6 +423,52 @@ export function Account() { } }; + const [driveLinkStatus, { refetch: refetchDriveLinkStatus }] = + createResource(async (): Promise => { + const response = await authServiceClient.checkGoogleDriveLinkStatus(); + if (response.isOk()) { + if (response.value.reauthentication_required) { + return 'reauthentication_required'; + } + return response.value.connected ? 'linked' : 'unlinked'; + } + return 'unlinked'; + }); + + const [driveImportOpen, setDriveImportOpen] = createSignal(false); + + // Google's OAuth redirect lands back here; the callback component finalizes + // the link (see GoogleDriveLinkCallback / Root.tsx). + const driveCallbackUrl = () => + `${window.location.origin}${ROUTER_BASE_CONCAT}drive-link-callback`; + + const handleDriveEnable = async () => { + const result = await authServiceClient.initGoogleDriveLink( + driveCallbackUrl() + ); + if (result.isOk()) { + window.location.href = result.value.authorization_url; + } else { + toast.failure('Failed to start Google Drive connect flow'); + } + }; + + const handleDriveDisable = async () => { + await authServiceClient.deleteGoogleDriveLink(); + refetchDriveLinkStatus(); + }; + + const handleDriveReconnect = async () => { + const result = await authServiceClient.reauthenticateGoogleDrive( + driveCallbackUrl() + ); + if (result.isOk()) { + window.location.href = result.value.authorization_url; + } else { + toast.failure('Failed to start Google Drive reconnect flow'); + } + }; + const firstName = () => { // Display any updated first name immediately without having to refetch if (updatedFirstName() !== undefined) return updatedFirstName(); @@ -759,6 +807,69 @@ export function Account() { + + Loading… + } + > +
+ + + + + Enable + + } + > + + + + + + + +
+
+
+ + + diff --git a/js/app/packages/app/component/settings/GoogleDriveImportDialog.tsx b/js/app/packages/app/component/settings/GoogleDriveImportDialog.tsx new file mode 100644 index 0000000000..045605ece2 --- /dev/null +++ b/js/app/packages/app/component/settings/GoogleDriveImportDialog.tsx @@ -0,0 +1,192 @@ +import GoogleDriveIcon from '@icon/mcp-google-drive.svg'; +import { toast } from '@core/component/Toast/Toast'; +import { + useGoogleDriveFoldersQuery, + useImportFromGoogleDrive, +} from '@queries/drive'; +import type { DriveFile } from '@service-storage/client'; +import { Button, Checkbox, Dialog } from '@ui'; +import { For, Show, createSignal } from 'solid-js'; + +const FOLDER_MIME = 'application/vnd.google-apps.folder'; + +/** + * A folder navigator for importing Drive content into Macro. The user drills + * into folders (breadcrumb to navigate back) and checks the files/folders to + * import; selecting a folder imports it recursively. Mirrors the + * `MoveToProjectView` flat-list pattern but browses one Drive folder at a time. + */ +export function GoogleDriveImportDialog(props: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + // `null` = the user's Drive root. + const [parentId, setParentId] = createSignal(null); + const [breadcrumb, setBreadcrumb] = createSignal< + { id: string; name: string }[] + >([]); + // Drive id -> name, for the items the user has selected to import. + const [selected, setSelected] = createSignal>({}); + + const foldersQuery = useGoogleDriveFoldersQuery(parentId, () => props.open); + const importMutation = useImportFromGoogleDrive(); + + const isFolder = (file: DriveFile) => file.mimeType === FOLDER_MIME; + const selectedCount = () => Object.keys(selected()).length; + + const toggle = (file: DriveFile) => { + setSelected((prev) => { + const next = { ...prev }; + if (next[file.id]) { + delete next[file.id]; + } else { + next[file.id] = file.name; + } + return next; + }); + }; + + const openFolder = (file: DriveFile) => { + setBreadcrumb((crumbs) => [...crumbs, { id: file.id, name: file.name }]); + setParentId(file.id); + }; + + // index === -1 navigates back to the root. + const navigateTo = (index: number) => { + if (index < 0) { + setBreadcrumb([]); + setParentId(null); + return; + } + const crumb = breadcrumb()[index]; + setBreadcrumb((crumbs) => crumbs.slice(0, index + 1)); + setParentId(crumb.id); + }; + + const reset = () => { + setParentId(null); + setBreadcrumb([]); + setSelected({}); + }; + + const handleImport = async () => { + const items = Object.keys(selected()).map((driveId) => ({ driveId })); + if (items.length === 0) { + return; + } + try { + const result = await importMutation.mutateAsync({ items }); + const count = result.imported.length; + toast.success( + `Imported ${count} item${count === 1 ? '' : 's'} from Google Drive` + ); + reset(); + props.onOpenChange(false); + } catch { + toast.failure('Failed to import from Google Drive'); + } + }; + + return ( + +
+
+ + + Import from Google Drive + +
+ +
+ + + {(crumb, index) => ( + <> + / + + + )} + +
+ +
+ Loading…
} + > + 0} + fallback={ +
+ This folder is empty. +
+ } + > + + {(file) => ( +
+ toggle(file)} + > + + + {file.name} + } + > + + +
+ )} +
+
+ +
+ +
+ {selectedCount()} selected +
+ + +
+
+ +
+ ); +} diff --git a/js/app/packages/icon/mcp-google-drive.svg b/js/app/packages/icon/mcp-google-drive.svg new file mode 100644 index 0000000000..ae7945ab0a --- /dev/null +++ b/js/app/packages/icon/mcp-google-drive.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/js/app/packages/queries/drive/folders.ts b/js/app/packages/queries/drive/folders.ts new file mode 100644 index 0000000000..920bcd7c48 --- /dev/null +++ b/js/app/packages/queries/drive/folders.ts @@ -0,0 +1,27 @@ +import { throwOnErr } from '@core/util/result'; +import { storageServiceClient } from '@service-storage/client'; +import { useQuery } from '@tanstack/solid-query'; +import type { Accessor } from 'solid-js'; +import { driveKeys } from './keys'; + +/** + * Query for the children of a Drive folder. Pass an accessor returning the + * parent folder id, or `null` for the user's Drive root. `enabled` gates the + * request (e.g. only browse Drive while the import dialog is open). + */ +export function useGoogleDriveFoldersQuery( + parentId: Accessor, + enabled?: Accessor +) { + return useQuery(() => ({ + queryKey: driveKeys.folders(parentId()).queryKey, + enabled: enabled ? enabled() : true, + queryFn: async () => + throwOnErr( + async () => + await storageServiceClient.listGoogleDriveFiles({ + parentId: parentId() ?? undefined, + }) + ), + })); +} diff --git a/js/app/packages/queries/drive/import.ts b/js/app/packages/queries/drive/import.ts new file mode 100644 index 0000000000..70a21abff2 --- /dev/null +++ b/js/app/packages/queries/drive/import.ts @@ -0,0 +1,16 @@ +import { throwOnErr } from '@core/util/result'; +import { + type DriveImportRequest, + storageServiceClient, +} from '@service-storage/client'; +import { useMutation } from '@tanstack/solid-query'; + +/** Mutation that imports the selected Drive files/folders into Macro. */ +export function useImportFromGoogleDrive() { + return useMutation(() => ({ + mutationFn: async (request: DriveImportRequest) => + throwOnErr( + async () => await storageServiceClient.importFromGoogleDrive(request) + ), + })); +} diff --git a/js/app/packages/queries/drive/index.ts b/js/app/packages/queries/drive/index.ts new file mode 100644 index 0000000000..4b45209b9d --- /dev/null +++ b/js/app/packages/queries/drive/index.ts @@ -0,0 +1,10 @@ +export { useGoogleDriveFoldersQuery } from './folders'; +export { useImportFromGoogleDrive } from './import'; +export { driveKeys } from './keys'; +export { + useDisconnectGoogleDrive, + useFinalizeGoogleDriveLink, + useGoogleDriveLinkStatusQuery, + useInitGoogleDriveLink, + useReauthenticateGoogleDrive, +} from './link'; diff --git a/js/app/packages/queries/drive/keys.ts b/js/app/packages/queries/drive/keys.ts new file mode 100644 index 0000000000..4563c88fed --- /dev/null +++ b/js/app/packages/queries/drive/keys.ts @@ -0,0 +1,8 @@ +import { createQueryKeys } from '@lukemorales/query-key-factory'; + +export const driveKeys = createQueryKeys('drive', { + connectionStatus: null, + folders: (parentId: string | null) => ({ + queryKey: [parentId ?? 'root'], + }), +}); diff --git a/js/app/packages/queries/drive/link.ts b/js/app/packages/queries/drive/link.ts new file mode 100644 index 0000000000..8fe2216029 --- /dev/null +++ b/js/app/packages/queries/drive/link.ts @@ -0,0 +1,63 @@ +import { throwOnErr } from '@core/util/result'; +import { authServiceClient } from '@service-auth/client'; +import { useMutation, useQuery } from '@tanstack/solid-query'; +import { useQueryClient } from '../client'; +import { driveKeys } from './keys'; + +/** Query for the user's Google Drive connection status. */ +export function useGoogleDriveLinkStatusQuery() { + return useQuery(() => ({ + queryKey: driveKeys.connectionStatus.queryKey, + queryFn: async () => + throwOnErr( + async () => await authServiceClient.checkGoogleDriveLinkStatus() + ), + })); +} + +/** + * Mutation that asks auth-service for the Google OAuth authorization URL. + * Callers consume `authorization_url` and navigate the browser to it. + */ +export function useInitGoogleDriveLink() { + return useMutation(() => ({ + mutationFn: async (originalUrl: string) => + authServiceClient.initGoogleDriveLink(originalUrl), + })); +} + +/** Mutation that persists the Drive link after the OAuth callback returns. */ +export function useFinalizeGoogleDriveLink() { + const queryClient = useQueryClient(); + return useMutation(() => ({ + mutationFn: async () => + throwOnErr(async () => await authServiceClient.finalizeGoogleDriveLink()), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: driveKeys.connectionStatus.queryKey, + }); + }, + })); +} + +/** Mutation that disconnects then re-initiates the Drive link ("Reconnect"). */ +export function useReauthenticateGoogleDrive() { + return useMutation(() => ({ + mutationFn: async (originalUrl: string) => + authServiceClient.reauthenticateGoogleDrive(originalUrl), + })); +} + +/** Mutation that disconnects the user's Google Drive account. */ +export function useDisconnectGoogleDrive() { + const queryClient = useQueryClient(); + return useMutation(() => ({ + mutationFn: async () => + throwOnErr(async () => await authServiceClient.deleteGoogleDriveLink()), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: driveKeys.connectionStatus.queryKey, + }); + }, + })); +} diff --git a/js/app/packages/service-clients/service-auth/client.ts b/js/app/packages/service-clients/service-auth/client.ts index 31c554c640..c902c92e0a 100644 --- a/js/app/packages/service-clients/service-auth/client.ts +++ b/js/app/packages/service-clients/service-auth/client.ts @@ -50,6 +50,19 @@ import type { UserNames } from './generated/schemas/userNames'; import type { UserOrganizationResponse } from './generated/schemas/userOrganizationResponse'; import type { UserTokensResponse } from './generated/schemas/userTokensResponse'; +// Google Drive link types. Defined inline until the OpenAPI client is +// regenerated (`bun scripts/generate-api-schema.ts auth-service`), at which +// point these can be imported from `./generated/schemas`. +type InitGoogleDriveLinkResponse = { + authorization_url: string; + link_id: string; +}; +type FinalizeGoogleDriveLinkResponse = { email: string }; +type GoogleDriveLinkStatusResponse = { + connected: boolean; + reauthentication_required: boolean; +}; + const authHost = SERVER_HOSTS['auth-service']; const authApiFetch = ( @@ -593,6 +606,60 @@ export const authServiceClient = { return authServiceClient.initGithubLink(originalUrl); }, + /** Whether the user has connected Google Drive (and whether it needs reauth). */ + async checkGoogleDriveLinkStatus() { + return ( + await fetchWithAuth( + `${authHost}/link/google-drive/status`, + { method: 'GET' } + ) + ).map((result) => result); + }, + + /** + * Initializes a Google Drive link for the already-authenticated user. + * Returns the OAuth authorization URL to redirect the browser to. After + * Google consent the user lands back on `originalUrl` with `?link_id=`; + * the frontend then calls {@link finalizeGoogleDriveLink}. + */ + async initGoogleDriveLink(originalUrl?: string) { + const url = originalUrl + ? `${authHost}/link/google-drive?original_url=${encodeURIComponent(originalUrl)}` + : `${authHost}/link/google-drive`; + return ( + await fetchWithAuth(url, { method: 'POST' }) + ).map((result) => result); + }, + + /** Persists the Google Drive link after the OAuth callback returns. */ + async finalizeGoogleDriveLink() { + return ( + await fetchWithAuth( + `${authHost}/link/google-drive/finalize`, + { method: 'POST' } + ) + ).map((result) => result); + }, + + /** Disconnects the user's Google Drive account. */ + async deleteGoogleDriveLink() { + return ( + await fetchWithAuth<{}>(`${authHost}/link/google-drive`, { + method: 'DELETE', + }) + ).map((_result) => {}); + }, + + /** Disconnects then re-initiates the Drive link (the "Reconnect" action). */ + async reauthenticateGoogleDrive(originalUrl?: string) { + const deleteResult = await authServiceClient.deleteGoogleDriveLink(); + if (deleteResult.isErr()) { + return err(deleteResult.error); + } + + return authServiceClient.initGoogleDriveLink(originalUrl); + }, + async sendMobileWelcomeEmail(email: string) { return safeFetch< SendMobileWelcomeEmailResponse, diff --git a/js/app/packages/service-clients/service-storage/client.ts b/js/app/packages/service-clients/service-storage/client.ts index f9e50fa1e1..c03dea1ae3 100644 --- a/js/app/packages/service-clients/service-storage/client.ts +++ b/js/app/packages/service-clients/service-storage/client.ts @@ -298,6 +298,39 @@ const enhancements = { const { showPaywall } = usePaywallState(); +// Google Drive browse/import types. Defined inline until the OpenAPI client is +// regenerated (`bun scripts/generate-api-schema.ts cloud-storage`). +export type DriveFile = { + id: string; + name: string; + mimeType: string; + parents?: string[]; + size?: string | null; + modifiedTime?: string | null; + webViewLink?: string | null; + trashed?: boolean; +}; +export type DriveFileList = { + files: DriveFile[]; + nextPageToken?: string | null; +}; +export type DriveImportItem = { driveId: string }; +export type DriveImportRequest = { + items: DriveImportItem[]; + destinationProjectId?: string | null; +}; +export type DriveImportedEntity = { + driveId: string; + macroId: string; + kind: 'folder' | 'document'; + name: string; +}; +export type DriveImportResult = { + imported: DriveImportedEntity[]; + skipped: number; +}; +export type DriveConnectionResponse = { connected: boolean }; + export const storageServiceClient = { async ping() { return (await dssFetch(`/ping`)).map( @@ -305,6 +338,33 @@ export const storageServiceClient = { ); }, + /** List the children of a Drive folder (omit `parentId` for the root). */ + async listGoogleDriveFiles(args?: { parentId?: string; pageToken?: string }) { + const params = new URLSearchParams(); + if (args?.parentId) params.set('parent_id', args.parentId); + if (args?.pageToken) params.set('page_token', args.pageToken); + const query = params.toString(); + return await dssFetch( + `/google-drive/files${query ? `?${query}` : ''}`, + { method: 'GET' } + ); + }, + + /** Import the selected Drive files/folders into Macro. */ + async importFromGoogleDrive(body: DriveImportRequest) { + return await dssFetch(`/google-drive/import`, { + method: 'POST', + body: JSON.stringify(body), + }); + }, + + /** Whether the user has connected Google Drive (storage-service view). */ + async googleDriveConnectionStatus() { + return await dssFetch(`/google-drive/connection`, { + method: 'GET', + }); + }, + async bulkWakeupSyncServiceDocuments(args: { document_ids: string[] }) { return ( await dssFetch<{ dispatched: number }>(`/sync_service/wakeup`, { diff --git a/rust/cloud-storage/Cargo.lock b/rust/cloud-storage/Cargo.lock index 4b26d299a3..1756f28ce9 100644 --- a/rust/cloud-storage/Cargo.lock +++ b/rust/cloud-storage/Cargo.lock @@ -660,6 +660,7 @@ dependencies = [ "fusionauth", "futures", "github", + "google_drive", "http-body-util", "jsonwebtoken 10.3.0", "last_online_tracker", @@ -3421,6 +3422,7 @@ dependencies = [ "ai_tools", "analytics_client", "anyhow", + "authentication_service_client", "aws-sdk-dynamodb", "aws-sdk-s3", "aws-sdk-secretsmanager", @@ -3458,6 +3460,7 @@ dependencies = [ "frecency", "futures", "github", + "google_drive", "hex", "http-body-util", "jsonwebtoken 10.3.0", @@ -3508,6 +3511,7 @@ dependencies = [ "secretsmanager_client", "serde", "serde_json", + "sha2 0.10.9", "soup", "sqlx", "sqs_client", @@ -4909,6 +4913,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "google_drive" +version = "0.1.0" +dependencies = [ + "anyhow", + "authentication_service_client", + "axum", + "axum-extra", + "bytes", + "chrono", + "futures", + "macro_user_id", + "macro_uuid", + "model-error-response", + "model_user", + "redis", + "reqwest 0.13.4", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "utoipa", + "uuid", +] + [[package]] name = "group" version = "0.13.0" diff --git a/rust/cloud-storage/Cargo.toml b/rust/cloud-storage/Cargo.toml index 2a16e91730..66dcd78021 100644 --- a/rust/cloud-storage/Cargo.toml +++ b/rust/cloud-storage/Cargo.toml @@ -30,6 +30,7 @@ members = [ "email_suppression_handler", "embedding", "foreign_entity", + "google_drive", "image_optimizer", "image_proxy_service", "integration_tests/local_e2e", diff --git a/rust/cloud-storage/authentication_service/Cargo.toml b/rust/cloud-storage/authentication_service/Cargo.toml index edda33fb6a..b407e1c972 100644 --- a/rust/cloud-storage/authentication_service/Cargo.toml +++ b/rust/cloud-storage/authentication_service/Cargo.toml @@ -52,6 +52,9 @@ github = { path = "../github", default-features = false, features = [ "link", "outbound", ] } +google_drive = { path = "../google_drive", default-features = false, features = [ + "db", +] } http-body-util = { workspace = true } jsonwebtoken = { workspace = true } last_online_tracker = { path = "../last_online_tracker" } diff --git a/rust/cloud-storage/authentication_service/src/api/internal/google_access_token.rs b/rust/cloud-storage/authentication_service/src/api/internal/google_access_token.rs index 52604022b6..4c0b185b8b 100644 --- a/rust/cloud-storage/authentication_service/src/api/internal/google_access_token.rs +++ b/rust/cloud-storage/authentication_service/src/api/internal/google_access_token.rs @@ -20,7 +20,7 @@ pub struct GoogleAccessTokenParams { email: String, } -/// Gets link between user and identity provider +/// Gets a Gmail access token for a user (the `google_gmail` identity provider). #[tracing::instrument(skip(auth_client, _internal_access))] pub async fn handler( State(auth_client): State>, @@ -30,6 +30,18 @@ pub async fn handler( get_access_token(auth_client, ¶ms, "google_gmail").await } +/// Gets a Google Drive access token for a user (the `google_drive` identity +/// provider). Used by `document_storage_service` to call the Drive API on the +/// user's behalf when browsing/importing. +#[tracing::instrument(skip(auth_client, _internal_access))] +pub async fn drive_handler( + State(auth_client): State>, + _internal_access: ValidInternalKey, + extract::Query(params): extract::Query, +) -> Result { + get_access_token(auth_client, ¶ms, "google_drive").await +} + /// Fetches access token for a user from specified identity provider #[tracing::instrument(skip(auth_client))] async fn get_access_token( diff --git a/rust/cloud-storage/authentication_service/src/api/internal/mod.rs b/rust/cloud-storage/authentication_service/src/api/internal/mod.rs index 3f8403e340..874db5f547 100644 --- a/rust/cloud-storage/authentication_service/src/api/internal/mod.rs +++ b/rust/cloud-storage/authentication_service/src/api/internal/mod.rs @@ -15,6 +15,10 @@ mod remove_link; pub fn router() -> Router { Router::new() .route("/google_access_token", get(google_access_token::handler)) + .route( + "/google_drive_access_token", + get(google_access_token::drive_handler), + ) .route("/get_names", post(post_get_names::handler_internal)) .route("/get_existing_users", get(post_get_existing_users::handler)) .route("/remove_link", delete(remove_link::handler)) diff --git a/rust/cloud-storage/authentication_service/src/api/link/google_drive.rs b/rust/cloud-storage/authentication_service/src/api/link/google_drive.rs new file mode 100644 index 0000000000..71cc3aeb52 --- /dev/null +++ b/rust/cloud-storage/authentication_service/src/api/link/google_drive.rs @@ -0,0 +1,336 @@ +//! Google Drive OAuth link endpoints. +//! +//! Mirrors [`super::gmail`]: the user is sent to Google's consent screen with +//! Drive scopes, Google redirects back to the shared `/oauth2/google/callback` +//! (which creates the FusionAuth identity-provider link), and the frontend then +//! calls `finalize` to persist a `google_drive_links` row. + +use anyhow::Context; +use axum::{ + Json, + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use chrono::Utc; +use google_drive::domain::models::{GOOGLE_DRIVE_IDENTITY_PROVIDER_NAME, GoogleDriveLink}; +use google_drive::domain::ports::GoogleDriveRepo; +use google_drive::outbound::PgGoogleDriveLinkRepo; +use macro_middleware::tracking::ClientIp; +use model::response::ErrorResponse; +use model_user::axum_extractor::MacroUserExtractor; +use serde_utils::urlencode::UrlEncoded; +use url::Url; +use uuid::Uuid; + +use crate::api::{context::ApiContext, oauth2::OAuthState}; + +const GOOGLE_AUTHORIZATION_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth"; +/// Full read/write Drive access — lets the user browse and import any of their +/// folder trees (and leaves room for future write-back features). +const GOOGLE_DRIVE_SCOPES: &str = "openid profile email https://www.googleapis.com/auth/drive"; + +/// Error type for Google Drive link operations. +#[derive(thiserror::Error, Debug)] +pub enum GoogleDriveLinkError { + /// Too many in-progress links for this user. + #[error("too many in progress links")] + TooManyInProgressLinks, + /// No Google Drive identity-provider link exists yet. + #[error("no google drive link found")] + NoLinkFound, + /// The `google_drive` identity provider was not found in FusionAuth. + #[error("identity provider not found")] + IdentityProviderNotFound, + /// Internal error. + #[error("internal error occurred")] + InternalError(#[from] anyhow::Error), +} + +impl IntoResponse for GoogleDriveLinkError { + fn into_response(self) -> Response { + let status_code = match &self { + GoogleDriveLinkError::TooManyInProgressLinks => StatusCode::TOO_MANY_REQUESTS, + GoogleDriveLinkError::NoLinkFound => StatusCode::NOT_FOUND, + GoogleDriveLinkError::IdentityProviderNotFound + | GoogleDriveLinkError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + + ( + status_code, + Json(ErrorResponse { + message: self.to_string().into(), + }), + ) + .into_response() + } +} + +#[derive(serde::Deserialize, serde::Serialize, Debug, utoipa::ToSchema)] +pub struct InitGoogleDriveLinkResponse { + /// The OAuth authorization URL to redirect the user to. + pub authorization_url: String, + /// The link ID for tracking the OAuth flow. + pub link_id: uuid::Uuid, +} + +#[derive(Debug, serde::Deserialize)] +pub(crate) struct InitGoogleDriveLinkQueryParams { + /// The original url to redirect back to after linking. + original_url: Option>, +} + +/// Initiates a Google Drive link for the authenticated user. +#[utoipa::path( + post, + operation_id = "init_google_drive_link", + path = "/link/google-drive", + params( + ("original_url" = String, Query, description = "**OPTIONAL**. The original url to redirect to.") + ), + responses( + (status = 200, body = InitGoogleDriveLinkResponse), + (status = 429, body = ErrorResponse), + (status = 401, body = ErrorResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(skip(ctx, ip_context, user_context), fields(client_ip = %ip_context, user_id = %user_context.user_context.user_id, fusion_user_id = %user_context.user_context.fusion_user_id), err)] +pub async fn init_google_drive_link_handler( + State(ctx): State, + query: Query, + ip_context: ClientIp, + user_context: MacroUserExtractor, +) -> Result, GoogleDriveLinkError> { + let Query(InitGoogleDriveLinkQueryParams { original_url }) = query; + + let count = + macro_db_client::in_progress_user_link::count_existing_in_progress_user_links_for_user( + &ctx.db, + &user_context.user_context.fusion_user_id, + ) + .await?; + + if count >= 5 { + return Err(GoogleDriveLinkError::TooManyInProgressLinks); + } + + let link_id = macro_db_client::in_progress_user_link::create_in_progress_user_link( + &ctx.db, + &user_context.user_context.fusion_user_id, + ) + .await?; + + let drive_idp_id = ctx + .auth_client + .get_identity_provider_id_by_name(GOOGLE_DRIVE_IDENTITY_PROVIDER_NAME) + .await + .map_err(|_| GoogleDriveLinkError::IdentityProviderNotFound)?; + + let state = OAuthState { + identity_provider_id: drive_idp_id, + link_id: Some(link_id), + original_url: original_url.map(|x| x.0.to_string()), + is_mobile: None, + }; + + let redirect_uri = crate::api::oauth2::format_redirect_uri("google"); + let state_str = serde_json::to_string(&state).context("failed to serialize OAuth state")?; + + let mut authorization_url = + Url::parse(GOOGLE_AUTHORIZATION_URL).context("invalid Google authorization URL")?; + authorization_url + .query_pairs_mut() + .append_pair("client_id", ctx.auth_client.google_client_id()) + .append_pair("redirect_uri", &redirect_uri) + .append_pair("response_type", "code") + .append_pair("scope", GOOGLE_DRIVE_SCOPES) + .append_pair("state", &state_str) + .append_pair("access_type", "offline") + .append_pair("prompt", "consent"); + + Ok(Json(InitGoogleDriveLinkResponse { + authorization_url: authorization_url.to_string(), + link_id, + })) +} + +#[derive(serde::Serialize, Debug, utoipa::ToSchema)] +pub struct FinalizeGoogleDriveLinkResponse { + /// The connected Google account email. + pub email: String, +} + +/// Finalizes a Google Drive link after the OAuth callback. +/// +/// The callback (`/oauth2/google/callback`) has already created the FusionAuth +/// identity-provider link; this authenticated endpoint reads the linked email +/// from FusionAuth and persists the `google_drive_links` row (so we have the +/// Macro user id, which the unauthenticated callback does not). +#[utoipa::path( + post, + operation_id = "finalize_google_drive_link", + path = "/link/google-drive/finalize", + responses( + (status = 200, body = FinalizeGoogleDriveLinkResponse), + (status = 404, body = ErrorResponse), + (status = 401, body = ErrorResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(skip(ctx, ip_context, user_context), fields(client_ip = %ip_context, user_id = %user_context.macro_user_id), err)] +pub async fn finalize_google_drive_link_handler( + State(ctx): State, + ip_context: ClientIp, + user_context: MacroUserExtractor, +) -> Result, GoogleDriveLinkError> { + let fusion_user_id = &user_context.user_context.fusion_user_id; + + let drive_idp_id = ctx + .auth_client + .get_identity_provider_id_by_name(GOOGLE_DRIVE_IDENTITY_PROVIDER_NAME) + .await + .map_err(|_| GoogleDriveLinkError::IdentityProviderNotFound)?; + + let links = ctx + .auth_client + .get_links(fusion_user_id, Some(drive_idp_id)) + .await + .map_err(|e| GoogleDriveLinkError::InternalError(e.into()))?; + + // We support one Drive link per user; pick the most recently created. + let link = links + .into_iter() + .max_by_key(|l| l.insert_instant) + .ok_or(GoogleDriveLinkError::NoLinkFound)?; + + let fusionauth_user_id = + Uuid::parse_str(fusion_user_id).context("invalid fusionauth user id")?; + let email = link.display_name; + + let now = Utc::now(); + PgGoogleDriveLinkRepo::new(ctx.db.clone()) + .upsert_link(&GoogleDriveLink { + id: macro_uuid::generate_uuid_v7(), + macro_id: user_context.macro_user_id.to_string(), + fusionauth_user_id, + email: email.clone(), + created_at: now, + updated_at: now, + }) + .await + .map_err(|e| GoogleDriveLinkError::InternalError(e.into()))?; + + Ok(Json(FinalizeGoogleDriveLinkResponse { email })) +} + +#[derive(serde::Serialize, Debug, utoipa::ToSchema)] +pub struct GoogleDriveLinkStatusResponse { + /// Whether the user has connected a Google Drive account. + pub connected: bool, + /// Whether a connected account needs to be reconnected (token revoked). + pub reauthentication_required: bool, +} + +/// Reports the Google Drive connection status for the authenticated user. +#[utoipa::path( + get, + operation_id = "check_google_drive_link_status", + path = "/link/google-drive/status", + responses( + (status = 200, body = GoogleDriveLinkStatusResponse), + (status = 401, body = ErrorResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(skip(ctx, ip_context, user_context), fields(client_ip = %ip_context, user_id = %user_context.macro_user_id), err)] +pub async fn check_google_drive_link_status_handler( + State(ctx): State, + ip_context: ClientIp, + user_context: MacroUserExtractor, +) -> Result, GoogleDriveLinkError> { + let connected = PgGoogleDriveLinkRepo::new(ctx.db.clone()) + .get_link_by_user_id(&user_context.macro_user_id.to_string()) + .await + .map_err(|e| GoogleDriveLinkError::InternalError(e.into()))? + .is_some(); + + if !connected { + return Ok(Json(GoogleDriveLinkStatusResponse { + connected: false, + reauthentication_required: false, + })); + } + + // Connected per our records — confirm the FusionAuth link still exists. If + // it's gone the user revoked access and must reconnect. + let drive_idp_id = ctx + .auth_client + .get_identity_provider_id_by_name(GOOGLE_DRIVE_IDENTITY_PROVIDER_NAME) + .await + .map_err(|_| GoogleDriveLinkError::IdentityProviderNotFound)?; + + let links = ctx + .auth_client + .get_links( + &user_context.user_context.fusion_user_id, + Some(drive_idp_id), + ) + .await + .map_err(|e| GoogleDriveLinkError::InternalError(e.into()))?; + + Ok(Json(GoogleDriveLinkStatusResponse { + connected: true, + reauthentication_required: links.is_empty(), + })) +} + +/// Disconnects the authenticated user's Google Drive account. +#[utoipa::path( + delete, + operation_id = "delete_google_drive_link", + path = "/link/google-drive", + responses( + (status = 204, description = "Disconnected"), + (status = 401, body = ErrorResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(skip(ctx, ip_context, user_context), fields(client_ip = %ip_context, user_id = %user_context.macro_user_id), err)] +pub async fn delete_google_drive_link_handler( + State(ctx): State, + ip_context: ClientIp, + user_context: MacroUserExtractor, +) -> Result { + let fusion_user_id = &user_context.user_context.fusion_user_id; + + let drive_idp_id = ctx + .auth_client + .get_identity_provider_id_by_name(GOOGLE_DRIVE_IDENTITY_PROVIDER_NAME) + .await + .map_err(|_| GoogleDriveLinkError::IdentityProviderNotFound)?; + + // Best-effort unlink of every Drive identity-provider link for this user. + if let Ok(links) = ctx + .auth_client + .get_links(fusion_user_id, Some(drive_idp_id.clone())) + .await + { + for link in links { + let _ = ctx + .auth_client + .unlink_user(fusion_user_id, &drive_idp_id, &link.identity_provider_user_id) + .await + .inspect_err(|e| { + tracing::error!(error = ?e, "failed to unlink google drive identity provider") + }); + } + } + + PgGoogleDriveLinkRepo::new(ctx.db.clone()) + .delete_link_by_user_id(&user_context.macro_user_id.to_string()) + .await + .map_err(|e| GoogleDriveLinkError::InternalError(e.into()))?; + + Ok(StatusCode::NO_CONTENT) +} diff --git a/rust/cloud-storage/authentication_service/src/api/link/mod.rs b/rust/cloud-storage/authentication_service/src/api/link/mod.rs index 592b123575..821bec368f 100644 --- a/rust/cloud-storage/authentication_service/src/api/link/mod.rs +++ b/rust/cloud-storage/authentication_service/src/api/link/mod.rs @@ -6,6 +6,7 @@ use axum::{ pub(in crate::api) mod create_in_progress_link; pub(in crate::api) mod github; pub(in crate::api) mod gmail; +pub(in crate::api) mod google_drive; /// The link router /// We ensure the user is logged in with the `macro_middleware::auth::decode_jwt::handler`. @@ -20,4 +21,20 @@ pub fn router(_state: ApiContext) -> Router { ) .route("/gmail", post(gmail::init_gmail_link_handler)) .route("/gmail/status", get(gmail::check_gmail_link_status_handler)) + .route( + "/google-drive", + post(google_drive::init_google_drive_link_handler), + ) + .route( + "/google-drive", + delete(google_drive::delete_google_drive_link_handler), + ) + .route( + "/google-drive/finalize", + post(google_drive::finalize_google_drive_link_handler), + ) + .route( + "/google-drive/status", + get(google_drive::check_google_drive_link_status_handler), + ) } diff --git a/rust/cloud-storage/authentication_service/src/api/swagger.rs b/rust/cloud-storage/authentication_service/src/api/swagger.rs index 7e0c455850..347f0baec9 100644 --- a/rust/cloud-storage/authentication_service/src/api/swagger.rs +++ b/rust/cloud-storage/authentication_service/src/api/swagger.rs @@ -22,6 +22,9 @@ use crate::api::jwt::macro_api_token::MacroApiTokenResponse; use crate::api::link::create_in_progress_link::CreateInProgressLinkResponse; use crate::api::link::github::{GithubLinkStatusResponse, InitGithubLinkResponse}; use crate::api::link::gmail::{GmailLinkStatusResponse, InitGmailLinkResponse}; +use crate::api::link::google_drive::{ + FinalizeGoogleDriveLinkResponse, GoogleDriveLinkStatusResponse, InitGoogleDriveLinkResponse, +}; use crate::api::merge::create_merge_request::CreateAccountMergeRequest; use crate::api::user::create_user::CreateUserRequest; use crate::api::user::get_legacy_user_permissions::GetLegacyUserPermissionsResponse; @@ -78,6 +81,10 @@ use model::user::{ link::github::check_github_link_status_handler, link::gmail::init_gmail_link_handler, link::gmail::check_gmail_link_status_handler, + link::google_drive::init_google_drive_link_handler, + link::google_drive::finalize_google_drive_link_handler, + link::google_drive::check_google_drive_link_status_handler, + link::google_drive::delete_google_drive_link_handler, /// /github_pull_requests github_pull_requests::handler, @@ -177,6 +184,9 @@ use model::user::{ GithubLinkStatusResponse, InitGmailLinkResponse, GmailLinkStatusResponse, + InitGoogleDriveLinkResponse, + FinalizeGoogleDriveLinkResponse, + GoogleDriveLinkStatusResponse, // GitHub pull requests EnrichGithubPullRequestsProxyRequest, diff --git a/rust/cloud-storage/authentication_service_client/src/google_drive_access_token.rs b/rust/cloud-storage/authentication_service_client/src/google_drive_access_token.rs new file mode 100644 index 0000000000..8635624522 --- /dev/null +++ b/rust/cloud-storage/authentication_service_client/src/google_drive_access_token.rs @@ -0,0 +1,66 @@ +use crate::AuthServiceClient; +use crate::error::{AuthServiceClientError, GenericErrorResponse}; +use model::authentication::google_token::GoogleAccessToken; + +impl AuthServiceClient { + /// Gets a Google **Drive** access token for the given fusionauth user and + /// linked email. Mirrors [`Self::get_google_access_token`] but resolves the + /// token from the `google_drive` identity provider rather than + /// `google_gmail`. `email` corresponds to the `display_name` on the + /// FusionAuth Drive IdP link (the connected Google account's email). + #[tracing::instrument(skip(self))] + pub async fn get_google_drive_access_token( + &self, + fusionauth_user_id: &str, + email: &str, + ) -> Result { + let res = self + .client + .get(format!("{}/internal/google_drive_access_token", self.url)) + .query(&[("fusionauth_user_id", fusionauth_user_id)]) + .query(&[("email", email)]) + .send() + .await + .map_err(|e| AuthServiceClientError::RequestBuildError { + details: e.to_string(), + })?; + + match res.status() { + reqwest::StatusCode::OK => { + tracing::trace!("user drive access token retrieved"); + let result = res.json::().await.map_err(|e| { + AuthServiceClientError::Generic(GenericErrorResponse { + message: e.to_string(), + }) + })?; + + Ok(result) + } + reqwest::StatusCode::UNAUTHORIZED => Err(AuthServiceClientError::Unauthorized), + reqwest::StatusCode::FORBIDDEN => Err(AuthServiceClientError::Forbidden), + reqwest::StatusCode::NOT_FOUND => Err(AuthServiceClientError::NotFound), + reqwest::StatusCode::INTERNAL_SERVER_ERROR => { + let error_message = res.text().await.map_err(|e| { + AuthServiceClientError::Generic(GenericErrorResponse { + message: e.to_string(), + }) + })?; + + Err(AuthServiceClientError::InternalServerError { + details: error_message, + }) + } + _ => { + let body = res.text().await.map_err(|e| { + AuthServiceClientError::Generic(GenericErrorResponse { + message: e.to_string(), + }) + })?; + + Err(AuthServiceClientError::Generic(GenericErrorResponse { + message: body, + })) + } + } + } +} diff --git a/rust/cloud-storage/authentication_service_client/src/lib.rs b/rust/cloud-storage/authentication_service_client/src/lib.rs index 70afd6fcfa..9669382de1 100644 --- a/rust/cloud-storage/authentication_service_client/src/lib.rs +++ b/rust/cloud-storage/authentication_service_client/src/lib.rs @@ -1,5 +1,6 @@ pub mod error; pub mod google_access_token; +pub mod google_drive_access_token; pub mod unlink; pub mod users; diff --git a/rust/cloud-storage/document_storage_service/Cargo.toml b/rust/cloud-storage/document_storage_service/Cargo.toml index e54c719a9d..655a4abb3b 100644 --- a/rust/cloud-storage/document_storage_service/Cargo.toml +++ b/rust/cloud-storage/document_storage_service/Cargo.toml @@ -23,6 +23,7 @@ location_check = [] [dependencies] anyhow = { workspace = true } ai_tools = { path = "../ai_tools" } +authentication_service_client = { path = "../authentication_service_client" } aws-sdk-dynamodb = { workspace = true } aws-sdk-s3 = { workspace = true } aws-sdk-secretsmanager = { workspace = true } @@ -64,6 +65,7 @@ entity_access_management = { path = "../entity_access_management" } foreign_entity = { path = "../foreign_entity", features = ["inbound"] } frecency = { path = "../frecency", features = ["postgres"] } futures = { workspace = true } +google_drive = { path = "../google_drive" } github = { path = "../github", default-features = false, features = [ "axum", "inbound", @@ -127,6 +129,7 @@ search_service = { path = "../search_service" } secretsmanager_client = { path = "../secretsmanager_client" } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } soup = { path = "../soup", features = ["axum", "inbound", "outbound"] } sqlx = { workspace = true } sqs_client = { path = "../sqs_client", default-features = false, features = [ diff --git a/rust/cloud-storage/document_storage_service/src/api/context.rs b/rust/cloud-storage/document_storage_service/src/api/context.rs index 05d184cf27..eaf2cd8cc2 100644 --- a/rust/cloud-storage/document_storage_service/src/api/context.rs +++ b/rust/cloud-storage/document_storage_service/src/api/context.rs @@ -1,6 +1,7 @@ use contacts::domain::service::SqsContactsIngress; use contacts::outbound::ingress::SqsContactsQueue; +use crate::service::google_drive_import_sink::GoogleDriveImportSink; use crate::{config::Config, service::s3::S3}; use axum::extract::FromRef; use bots::{ @@ -44,6 +45,7 @@ use connection_gateway_client::client::ConnectionGatewayClient; use documents_hex::domain::ports::TaskPropertiesPort; use documents_hex::domain::service::DocumentServiceImpl; use documents_hex::inbound::axum_router::DocumentRouterState; +use documents_hex::outbound::document_bytes_upload::ReqwestDocumentBytesUploader; use documents_hex::outbound::pg_document_repo::PgDocumentRepo; use documents_hex::outbound::s3_upload_url::S3UploadUrlAdapter; use dynamodb_client::DynamodbClient; @@ -60,6 +62,8 @@ use frecency::{domain::services::FrecencyQueryServiceImpl, outbound::postgres::F use github::domain::service::GithubSyncServiceImpl; use github::outbound::github_sync_client::GithubSyncClientImpl; use github::outbound::pg_github_sync_repo::PgGithubSyncRepo; +use google_drive::domain::service::GoogleDriveServiceImpl; +use google_drive::outbound::{AuthServiceAccessTokens, DriveApiClient, PgGoogleDriveLinkRepo}; use macro_auth::middleware::decode_jwt::JwtValidationArgs; use macro_env_var::env_var; use macro_sha_count_client::Redis; @@ -289,6 +293,20 @@ pub(crate) type GithubSyncServiceType = GithubSyncServiceImpl< NotificationIngressType, >; +/// Type alias for the Google Drive import sink (Macro-storage adapter). The +/// document + foreign-entity services are stored behind `Arc` inside the sink, +/// so the generic params here are the concrete inner service types. +pub(crate) type GoogleDriveImportSinkType = + GoogleDriveImportSink; + +/// Type alias for the Google Drive browse + import service. +pub(crate) type GoogleDriveServiceType = GoogleDriveServiceImpl< + DriveApiClient, + AuthServiceAccessTokens, + PgGoogleDriveLinkRepo, + GoogleDriveImportSinkType, +>; + /// Type alias for the cal.com webhook service. pub(crate) type CalWebhookServiceType = CalWebhookServiceImpl; @@ -302,6 +320,7 @@ pub(crate) struct ApiContext { pub redis_client: Arc, pub s3_client: Arc, pub github_sync_service: Arc, + pub google_drive_service: Arc, pub dynamodb_client: Arc, pub dynamo_db: aws_sdk_dynamodb::Client, pub soup_router_state: DssSoupState, diff --git a/rust/cloud-storage/document_storage_service/src/api/mod.rs b/rust/cloud-storage/document_storage_service/src/api/mod.rs index c1c193fe0c..3e35a4ba5f 100644 --- a/rust/cloud-storage/document_storage_service/src/api/mod.rs +++ b/rust/cloud-storage/document_storage_service/src/api/mod.rs @@ -226,6 +226,14 @@ fn api_router(state: ApiContext) -> Router { "/crm", crm::inbound::axum_router::crm_router(state.crm_state.clone()), ) + .nest( + "/google-drive", + google_drive::inbound::google_drive_router::google_drive_router( + google_drive::inbound::google_drive_router::GoogleDriveRouterState { + service: state.google_drive_service.clone(), + }, + ), + ) .layer( ServiceBuilder::new() .layer(axum::middleware::from_fn( diff --git a/rust/cloud-storage/document_storage_service/src/main.rs b/rust/cloud-storage/document_storage_service/src/main.rs index 9edacceeda..627b9a9707 100644 --- a/rust/cloud-storage/document_storage_service/src/main.rs +++ b/rust/cloud-storage/document_storage_service/src/main.rs @@ -418,6 +418,33 @@ async fn main() -> anyhow::Result<()> { GithubSyncClientImpl::default(), ); + // Google Drive browse + import. Access tokens are resolved via + // authentication_service (which holds the FusionAuth refresh token), using + // the shared internal API key — the same one the connection gateway client + // uses above. + let google_drive_token_conn = redis_client + .get_multiplexed_async_connection() + .await + .context("failed to open redis connection for google drive tokens")?; + let auth_service_client = Arc::new(authentication_service_client::AuthServiceClient::new( + internal_api_secret.as_ref().to_string(), + macro_service_urls::AuthServiceUrl::new()?.to_string(), + )); + let google_drive_service = Arc::new(google_drive::domain::service::GoogleDriveServiceImpl::new( + google_drive::outbound::DriveApiClient::new(), + google_drive::outbound::AuthServiceAccessTokens::new( + auth_service_client, + google_drive_token_conn, + ), + google_drive::outbound::PgGoogleDriveLinkRepo::new(db.clone()), + crate::service::google_drive_import_sink::GoogleDriveImportSink::new( + document_service.clone(), + documents_hex::outbound::document_bytes_upload::ReqwestDocumentBytesUploader::default(), + foreign_entity_service.clone(), + db.clone(), + ), + )); + let foreign_entity_state = ForeignEntityRouterState::new( foreign_entity_service.clone(), entity_access_service.clone(), @@ -648,6 +675,7 @@ async fn main() -> anyhow::Result<()> { entity_access_service.clone(), ), github_sync_service: Arc::new(github_sync_service_impl), + google_drive_service, foreign_entity_state, db: db.clone(), readonly_db: readonly_pool::ReadOnlyPool(readonly_db.clone()), diff --git a/rust/cloud-storage/document_storage_service/src/service/google_drive_import_sink.rs b/rust/cloud-storage/document_storage_service/src/service/google_drive_import_sink.rs new file mode 100644 index 0000000000..173859b262 --- /dev/null +++ b/rust/cloud-storage/document_storage_service/src/service/google_drive_import_sink.rs @@ -0,0 +1,267 @@ +//! [`DriveImportSink`] adapter: materializes imported Google Drive content as +//! Macro Projects and Documents. +//! +//! This is the Macro-storage half of the Google Drive import. It reuses the +//! existing document-creation pipeline (presigned upload + content lifecycle, +//! the same path [`documents_hex`]'s `create_text_file` uses) and the project +//! creation helper, and records each Drive → Macro mapping as a +//! `foreign_entity` row so imports can later be de-duplicated and traced back +//! to their source. + +use anyhow::Context; +use base64::Engine; +use documents_hex::domain::content::DocumentContent; +use documents_hex::domain::models::CreateDocumentRepoArgs; +use documents_hex::domain::ports::create::{ + DocumentBytesUpload, DocumentBytesUploadPort, DocumentCreationService, +}; +use foreign_entity::domain::models::CreateForeignEntity; +use foreign_entity::domain::ports::ForeignEntityService; +use google_drive::domain::models::{GOOGLE_DRIVE_FOREIGN_ENTITY_SOURCE, ImportFileArgs}; +use google_drive::domain::ports::DriveImportSink; +use macro_user_id::user_id::MacroUserIdStr; +use model::document::{FileType, FileTypeExt}; +use models_permissions::share_permission::SharePermissionV2; +use sha2::{Digest, Sha256}; +use sqlx::PgPool; +use std::str::FromStr; +use std::sync::Arc; +use uuid::Uuid; + +/// The `stored_for_auth_entity` namespace for per-user imports. +const STORED_FOR_USER: &str = "user"; + +/// Sink that writes imported Drive content into Macro storage. +/// +/// The service/foreign-entity ports are stored behind `Arc` (mirroring +/// `GithubSyncServiceImpl`) so the generic params are the concrete inner types +/// — `ForeignEntityService` has no blanket `Arc` impl, so we can't hand it an +/// `Arc` as the type parameter directly. +pub struct GoogleDriveImportSink { + document_service: Arc, + bytes_uploader: Upload, + foreign_entity_service: Arc, + db: PgPool, +} + +impl GoogleDriveImportSink +where + DSvc: DocumentCreationService, + Upload: DocumentBytesUploadPort, + FE: ForeignEntityService, +{ + /// Create a new sink from the document-creation pipeline, a bytes uploader, + /// the foreign-entity service, and a DB pool (for project creation). + pub fn new( + document_service: Arc, + bytes_uploader: Upload, + foreign_entity_service: Arc, + db: PgPool, + ) -> Self { + Self { + document_service, + bytes_uploader, + foreign_entity_service, + db, + } + } + + /// Best-effort: record a Drive id → Macro entity id mapping. Failures are + /// logged but never fail the import (the entity is already created). + async fn record_foreign_entity( + &self, + drive_id: &str, + owner_macro_user_id: &str, + macro_entity_id: &str, + kind: &str, + name: &str, + web_view_link: Option<&str>, + mime_type: Option<&str>, + ) { + let metadata = serde_json::json!({ + "kind": kind, + "macroId": macro_entity_id, + "name": name, + "webViewLink": web_view_link, + "mimeType": mime_type, + }); + + let _ = self + .foreign_entity_service + .create_foreign_entity(CreateForeignEntity { + foreign_entity_id: drive_id.to_string(), + foreign_entity_source: GOOGLE_DRIVE_FOREIGN_ENTITY_SOURCE.to_string(), + metadata, + stored_for_id: owner_macro_user_id.to_string(), + stored_for_auth_entity: STORED_FOR_USER.to_string(), + }) + .await + .inspect_err(|e| { + tracing::warn!(error = ?e, drive_id, "failed to record google drive foreign entity") + }); + } +} + +impl DriveImportSink for GoogleDriveImportSink +where + DSvc: DocumentCreationService, + Upload: DocumentBytesUploadPort, + FE: ForeignEntityService, +{ + type Err = anyhow::Error; + + #[tracing::instrument(skip(self), err)] + async fn create_folder( + &self, + macro_user_id: &str, + name: &str, + parent_macro_project_id: Option<&str>, + drive_id: &str, + web_view_link: Option<&str>, + ) -> Result { + let user_id = MacroUserIdStr::parse_from_str(macro_user_id) + .map_err(|e| anyhow::anyhow!("invalid macro user id: {e}"))? + .into_owned(); + + let share_permission = SharePermissionV2::new_project_share_permission(); + let project = macro_db_client::projects::create_project_v2( + self.db.clone(), + user_id, + name, + parent_macro_project_id.map(str::to_owned), + &share_permission, + ) + .await + .context("failed to create project for imported drive folder")?; + + self.record_foreign_entity( + drive_id, + macro_user_id, + &project.id, + "folder", + name, + web_view_link, + None, + ) + .await; + + Ok(project.id) + } + + #[tracing::instrument(skip(self, args), fields(drive_id = %args.drive_id, name = %args.name), err)] + async fn import_file( + &self, + macro_user_id: &str, + args: ImportFileArgs, + ) -> Result { + let user_id = MacroUserIdStr::parse_from_str(macro_user_id) + .map_err(|e| anyhow::anyhow!("invalid macro user id: {e}"))? + .into_owned(); + + // Split the name into a stem + recognized extension to derive the file + // type (Drive-native docs were already exported to a concrete format). + let (document_name, file_type) = match FileType::split_suffix_match(&args.name) { + Some((stem, ext)) => (stem.to_string(), FileType::from_str(ext).ok()), + None => (args.name.clone(), None), + }; + + let project_id = match &args.parent_macro_project_id { + Some(id) => Some(Uuid::parse_str(id).context("invalid parent project id")?), + None => None, + }; + + let shas = file_shas(&args.content); + let repo_args = CreateDocumentRepoArgs { + id: None, + sha: shas.hex, + document_name, + user_id: user_id.clone(), + file_type, + project_id, + team_id: None, + email_attachment_id: None, + created_at: None, + is_task: false, + skip_history: false, + }; + + let response = self + .document_service + .create_document(user_id, repo_args, None) + .await + .context("failed to create document row for imported drive file")?; + + let document_id = response + .document_response + .document_metadata + .metadata + .document_id + .clone(); + + // Upload the content to the presigned URL, then mark the content ready. + // On any failure, clean up the half-created document. + let finalize = async { + let presigned_url = response + .document_response + .presigned_url + .as_ref() + .context("expected a presigned upload url")?; + + self.bytes_uploader + .upload_document_bytes(DocumentBytesUpload { + presigned_url: presigned_url.clone(), + content_type: response.content_type.clone(), + base64_sha256: shas.base64, + bytes: args.content, + }) + .await + .context("failed to upload imported drive file content")?; + + self.document_service + .set_document_content( + &document_id, + DocumentContent::from_legacy_uploaded(true, file_type), + ) + .await + .context("failed to mark imported document content ready")?; + + Ok::<(), anyhow::Error>(()) + } + .await; + + if let Err(error) = finalize { + self.document_service + .cleanup_created_document(&document_id) + .await; + return Err(error); + } + + self.record_foreign_entity( + &args.drive_id, + macro_user_id, + &document_id, + "document", + &args.name, + args.web_view_link.as_deref(), + Some(&args.mime_type), + ) + .await; + + Ok(document_id) + } +} + +struct FileShas { + hex: String, + base64: String, +} + +/// Compute the hex and base64 SHA-256 of the content, matching the document +/// service's expectations (hex for the row, base64 for the S3 checksum header). +fn file_shas(content: &[u8]) -> FileShas { + let digest = Sha256::digest(content); + FileShas { + hex: format!("{digest:x}"), + base64: base64::engine::general_purpose::STANDARD.encode(digest), + } +} diff --git a/rust/cloud-storage/document_storage_service/src/service/mod.rs b/rust/cloud-storage/document_storage_service/src/service/mod.rs index d27eca2441..1ff2e3863d 100644 --- a/rust/cloud-storage/document_storage_service/src/service/mod.rs +++ b/rust/cloud-storage/document_storage_service/src/service/mod.rs @@ -1,4 +1,5 @@ pub mod call_search_indexer; pub mod conn_gateway; pub mod delete_document_worker; +pub mod google_drive_import_sink; pub mod s3; diff --git a/rust/cloud-storage/google_drive/Cargo.toml b/rust/cloud-storage/google_drive/Cargo.toml new file mode 100644 index 0000000000..2009f1be57 --- /dev/null +++ b/rust/cloud-storage/google_drive/Cargo.toml @@ -0,0 +1,56 @@ +[package] +edition = "2024" +name = "google_drive" +publish = false +version = "0.1.0" + +[features] +default = ["axum", "db", "http", "inbound", "outbound", "ports", "tokens"] +# Pure trait definitions (domain ports). Always cheap to compile. +ports = [] +# Drive REST v3 client adapter. +http = ["dep:bytes", "dep:reqwest", "dep:url", "ports"] +# Access-token adapter that calls authentication_service + caches in redis. +tokens = ["dep:authentication_service_client", "dep:redis", "dep:tokio", "ports"] +# Postgres adapter for the `google_drive_links` table (requires a prepared sqlx cache). +db = ["dep:sqlx", "ports"] +# All outbound adapters. +outbound = ["db", "http", "tokens"] +# Axum inbound router. +inbound = ["axum", "ports"] +axum = ["dep:axum", "dep:axum-extra", "dep:model-error-response", "dep:model_user", "dep:utoipa"] + +[dependencies] +anyhow = { workspace = true } +chrono = { workspace = true } +futures = { workspace = true } +macro_user_id = { path = "../macro_user_id" } +macro_uuid = { path = "../macro_uuid" } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +# http +bytes = { workspace = true, optional = true } +reqwest = { workspace = true, optional = true } +url = { workspace = true, optional = true } + +# tokens +authentication_service_client = { path = "../authentication_service_client", optional = true } +redis = { workspace = true, features = ["aio", "tokio-comp"], optional = true } +tokio = { workspace = true, features = ["sync"], optional = true } + +# db +sqlx = { workspace = true, optional = true } + +# inbound / axum +axum = { workspace = true, optional = true } +axum-extra = { workspace = true, optional = true } +model-error-response = { path = "../model-error-response", optional = true } +model_user = { path = "../model_user", optional = true } +utoipa = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/rust/cloud-storage/google_drive/src/domain.rs b/rust/cloud-storage/google_drive/src/domain.rs new file mode 100644 index 0000000000..abfa5ac96d --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain.rs @@ -0,0 +1,5 @@ +//! Domain layer: models, ports, and service implementations. + +pub mod models; +pub mod ports; +pub mod service; diff --git a/rust/cloud-storage/google_drive/src/domain/models.rs b/rust/cloud-storage/google_drive/src/domain/models.rs new file mode 100644 index 0000000000..c6464c36a6 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/models.rs @@ -0,0 +1,27 @@ +//! Domain models for the Google Drive integration. + +mod drive; +mod error; +mod import; +mod link; + +pub use drive::{ + DriveFile, DriveFileList, FOLDER_MIME_TYPE, GOOGLE_APPS_MIME_PREFIX, export_target_for, + is_google_apps_doc, +}; +pub use error::GoogleDriveError; +pub use import::{ + ImportFileArgs, ImportItem, ImportRequest, ImportResult, ImportedEntity, ImportedKind, +}; +pub use link::GoogleDriveLink; + +/// The `foreign_entity.foreign_entity_source` value used to tag entities that +/// originated from Google Drive. Stored alongside the Drive file/folder id so +/// we can later de-duplicate imports and link back to the source. +pub const GOOGLE_DRIVE_FOREIGN_ENTITY_SOURCE: &str = "google_drive"; + +/// The FusionAuth identity-provider name for the Google Drive OAuth link. +/// +/// Mirrors `google_gmail`; the identity provider itself is provisioned in +/// `infra/stacks/fusionauth-instance`. +pub const GOOGLE_DRIVE_IDENTITY_PROVIDER_NAME: &str = "google_drive"; diff --git a/rust/cloud-storage/google_drive/src/domain/models/drive.rs b/rust/cloud-storage/google_drive/src/domain/models/drive.rs new file mode 100644 index 0000000000..20e41c9edd --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/models/drive.rs @@ -0,0 +1,108 @@ +//! Models mirroring the Google Drive REST API v3 `files` resource. + +use serde::{Deserialize, Serialize}; + +/// MIME type Google Drive uses to represent a folder. +pub const FOLDER_MIME_TYPE: &str = "application/vnd.google-apps.folder"; + +/// Prefix shared by all Google-native document MIME types (Docs, Sheets, +/// Slides, …). These cannot be downloaded directly and must be exported to a +/// concrete format via `files.export`. +pub const GOOGLE_APPS_MIME_PREFIX: &str = "application/vnd.google-apps."; + +/// A single Google Drive file or folder. +/// +/// Only the fields we request (and care about) are modelled. The Drive API +/// returns `camelCase`; `size` is a stringified integer and is absent for +/// folders and Google-native docs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DriveFile { + /// The Drive file id (stable, opaque). + pub id: String, + /// Human-readable name (no path). + pub name: String, + /// The Drive MIME type. Folders use [`FOLDER_MIME_TYPE`]. + pub mime_type: String, + /// Parent folder ids. The Drive root has no parents. + #[serde(default)] + pub parents: Vec, + /// Size in bytes, when known. Absent for folders / Google-native docs. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Last-modified timestamp (RFC 3339). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified_time: Option, + /// A link that opens the file in the Drive web UI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_view_link: Option, + /// Whether the file lives in the owner's trash. + #[serde(default)] + pub trashed: bool, +} + +impl DriveFile { + /// Whether this entry is a folder. + pub fn is_folder(&self) -> bool { + self.mime_type == FOLDER_MIME_TYPE + } + + /// Whether this entry is a Google-native document (Docs/Sheets/Slides/…) + /// that must be exported rather than downloaded directly. + pub fn is_google_apps_doc(&self) -> bool { + is_google_apps_doc(&self.mime_type) + } + + /// Size in bytes if the Drive API reported a parseable value. + pub fn size_bytes(&self) -> Option { + self.size.as_deref().and_then(|s| s.parse().ok()) + } +} + +/// A page of [`DriveFile`]s returned by `files.list`. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct DriveFileList { + /// The files in this page. + #[serde(default)] + pub files: Vec, + /// Opaque cursor for the next page, when more results exist. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_page_token: Option, +} + +/// Whether a Drive MIME type denotes a Google-native document. +pub fn is_google_apps_doc(mime_type: &str) -> bool { + mime_type.starts_with(GOOGLE_APPS_MIME_PREFIX) && mime_type != FOLDER_MIME_TYPE +} + +/// The format we export a Google-native document to when importing. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ExportTarget { + /// The `mimeType` to pass to `files.export`. + pub export_mime: &'static str, + /// The file extension Macro should store the exported content under. + pub extension: &'static str, +} + +const MIME_PDF: &str = "application/pdf"; + +/// Pick an export format for a Google-native MIME type. +/// +/// Returns `None` for non-Google-native files (which are downloaded as-is) and +/// for folders. Google-native docs (Docs/Sheets/Slides/Drawings) are exported +/// to **PDF**: PDF is a first-class static type in Macro that lands directly in +/// object storage and is immediately viewable, so it needs no conversion +/// pipeline. (Exporting to editable Office formats — docx/xlsx/pptx — is a +/// natural follow-up.) +pub fn export_target_for(mime_type: &str) -> Option { + if !is_google_apps_doc(mime_type) { + return None; + } + Some(ExportTarget { + export_mime: MIME_PDF, + extension: "pdf", + }) +} diff --git a/rust/cloud-storage/google_drive/src/domain/models/error.rs b/rust/cloud-storage/google_drive/src/domain/models/error.rs new file mode 100644 index 0000000000..681f829330 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/models/error.rs @@ -0,0 +1,21 @@ +//! Error type for the Google Drive integration. + +/// Errors surfaced by the Google Drive domain services. +#[derive(Debug, thiserror::Error)] +pub enum GoogleDriveError { + /// The user has not connected a Google Drive account. + #[error("no google drive link found")] + NoLinkFound, + /// The stored refresh token is invalid/revoked; the user must reconnect. + #[error("google drive reauthentication required")] + ReauthenticationRequired, + /// A requested Drive file/folder does not exist or is not accessible. + #[error("google drive resource not found")] + NotFound, + /// The Google Drive API returned an unexpected error. + #[error("google drive api error: {0}")] + DriveApi(String), + /// An internal error occurred. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} diff --git a/rust/cloud-storage/google_drive/src/domain/models/import.rs b/rust/cloud-storage/google_drive/src/domain/models/import.rs new file mode 100644 index 0000000000..54ea91e7c1 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/models/import.rs @@ -0,0 +1,95 @@ +//! Request/response models for importing Drive content into Macro. + +use serde::{Deserialize, Serialize}; + +/// A request to import a set of Drive files/folders into Macro. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ImportRequest { + /// The Drive files/folders the user selected to import. Folders are + /// imported recursively. + pub items: Vec, + /// Optional Macro project (folder) to import into. `None` imports into the + /// user's root. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub destination_project_id: Option, +} + +/// A single selected Drive node. Only the id is needed; the service fetches +/// authoritative metadata from Drive rather than trusting the client. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ImportItem { + /// The Drive file/folder id to import. + pub drive_id: String, +} + +/// The kind of Macro entity produced by an import. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum ImportedKind { + /// A Macro Project (folder). + Folder, + /// A Macro Document (file). + Document, +} + +/// A single entity produced by an import, mapping a Drive id to its new Macro id. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ImportedEntity { + /// The originating Drive file/folder id. + pub drive_id: String, + /// The id of the created Macro Project or Document. + pub macro_id: String, + /// Whether a folder or document was created. + pub kind: ImportedKind, + /// The name of the created entity. + pub name: String, +} + +/// Summary of an import run. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[cfg_attr(feature = "axum", derive(utoipa::ToSchema))] +#[serde(rename_all = "camelCase")] +pub struct ImportResult { + /// The entities successfully created in Macro. + pub imported: Vec, + /// Number of Drive items skipped (trashed, unsupported, or already imported). + pub skipped: u32, +} + +impl ImportResult { + /// Record a successfully imported entity. + pub fn push(&mut self, entity: ImportedEntity) { + self.imported.push(entity); + } + + /// Record a skipped Drive item. + pub fn skip(&mut self) { + self.skipped += 1; + } +} + +/// Everything the storage sink needs to materialize one Drive file as a Macro +/// Document. Internal (service → sink); never crosses the API boundary. +#[derive(Debug, Clone)] +pub struct ImportFileArgs { + /// The originating Drive file id (recorded as a foreign entity). + pub drive_id: String, + /// The file name **including** the extension it should be stored under + /// (already adjusted for exported Google-native docs, e.g. `Notes.docx`). + pub name: String, + /// The original Drive MIME type, stored in foreign-entity metadata. + pub mime_type: String, + /// A link back to the file in Drive, when available. + pub web_view_link: Option, + /// The Macro Project this document should be created in, if any. + pub parent_macro_project_id: Option, + /// The downloaded (or exported) file content. + pub content: Vec, +} diff --git a/rust/cloud-storage/google_drive/src/domain/models/link.rs b/rust/cloud-storage/google_drive/src/domain/models/link.rs new file mode 100644 index 0000000000..dd5c4770ca --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/models/link.rs @@ -0,0 +1,27 @@ +//! The persisted record of a user's Google Drive connection. + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// A row in the `google_drive_links` table: the binding between a Macro user, +/// their FusionAuth identity, and the connected Google account. +/// +/// The OAuth refresh token itself is **not** stored here — it lives in +/// FusionAuth (as the identity-provider link). We only persist enough to (a) +/// know the user is connected and (b) resolve the Drive account email that the +/// access-token endpoint keys off. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GoogleDriveLink { + /// Surface id of the link row. + pub id: Uuid, + /// The Macro user id that owns this link. + pub macro_id: String, + /// The FusionAuth user id, used to retrieve access tokens. + pub fusionauth_user_id: Uuid, + /// The connected Google account email (the FusionAuth link `displayName`). + pub email: String, + /// When the link was created. + pub created_at: DateTime, + /// When the link was last updated. + pub updated_at: DateTime, +} diff --git a/rust/cloud-storage/google_drive/src/domain/ports.rs b/rust/cloud-storage/google_drive/src/domain/ports.rs new file mode 100644 index 0000000000..5edcb51c31 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/ports.rs @@ -0,0 +1,154 @@ +//! Port definitions (traits) for the Google Drive integration. +//! +//! Adapters live in [`crate::outbound`] (Drive REST client, access-token +//! client, Postgres link repo) and in the calling service for +//! [`DriveImportSink`] (the Macro-storage details). + +use std::future::Future; + +use uuid::Uuid; + +use crate::domain::models::{ + DriveFile, DriveFileList, GoogleDriveError, GoogleDriveLink, ImportFileArgs, ImportRequest, + ImportResult, +}; + +/// Read access to the Google Drive REST API v3. +pub trait DriveApi: Send + Sync + 'static { + /// Error type returned by Drive API calls. + type Err: Into + Send + std::fmt::Debug; + + /// List the direct children of a folder (`folder_id`, or the special id + /// `"root"`). Trashed items are excluded. Returns one page; follow + /// [`DriveFileList::next_page_token`] for the rest. + fn list_children( + &self, + access_token: &str, + folder_id: &str, + page_token: Option<&str>, + ) -> impl Future> + Send; + + /// Fetch a single file/folder's metadata. + fn get_file( + &self, + access_token: &str, + file_id: &str, + ) -> impl Future> + Send; + + /// Download a binary (non-Google-native) file's content. + fn download_file( + &self, + access_token: &str, + file_id: &str, + ) -> impl Future, Self::Err>> + Send; + + /// Export a Google-native document (Docs/Sheets/Slides) to `export_mime`. + fn export_file( + &self, + access_token: &str, + file_id: &str, + export_mime: &str, + ) -> impl Future, Self::Err>> + Send; +} + +/// Error returned when resolving a Drive access token. +#[derive(Debug, thiserror::Error)] +pub enum AccessTokenError { + /// The refresh token is invalid/revoked — the user must reconnect Drive. + #[error("reauthentication required")] + ReauthenticationRequired, + /// Any other failure resolving the token. + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +/// Resolves a fresh Google access token for the connected Drive account. +/// +/// Backed by `authentication_service`, which holds the refresh token in +/// FusionAuth and exchanges it for a short-lived access token. +pub trait DriveAccessTokens: Send + Sync + 'static { + /// Resolve a fresh access token for the given FusionAuth user + Drive email. + fn retrieve_access_token( + &self, + fusionauth_user_id: &Uuid, + email: &str, + ) -> impl Future> + Send; +} + +/// Persistence for `google_drive_links` rows. +pub trait GoogleDriveRepo: Send + Sync + 'static { + /// Error type returned by repository operations. + type Err: Into + Send + std::fmt::Debug; + + /// Fetch the user's Drive link, or `None` if they have not connected. + fn get_link_by_user_id( + &self, + macro_user_id: &str, + ) -> impl Future, Self::Err>> + Send; + + /// Insert (or replace) the user's Drive link. + fn upsert_link( + &self, + link: &GoogleDriveLink, + ) -> impl Future> + Send; + + /// Delete the user's Drive link, if any. + fn delete_link_by_user_id( + &self, + macro_user_id: &str, + ) -> impl Future> + Send; +} + +/// Sink that materializes imported Drive content as Macro entities. +/// +/// Implemented by the calling service (`document_storage_service`), which owns +/// the Document/Project/S3/foreign-entity machinery. Each method also records +/// the Drive → Macro mapping as a `foreign_entity` row. +pub trait DriveImportSink: Send + Sync + 'static { + /// Error type returned by sink operations. + type Err: Into + Send + std::fmt::Debug; + + /// Create a Macro Project mirroring a Drive folder. Returns the new + /// project id (used as the parent for the folder's children). + fn create_folder( + &self, + macro_user_id: &str, + name: &str, + parent_macro_project_id: Option<&str>, + drive_id: &str, + web_view_link: Option<&str>, + ) -> impl Future> + Send; + + /// Create a Macro Document from downloaded Drive content. Returns the new + /// document id. + fn import_file( + &self, + macro_user_id: &str, + args: ImportFileArgs, + ) -> impl Future> + Send; +} + +/// High-level Google Drive operations exposed to the inbound HTTP layer. +pub trait GoogleDriveService: Send + Sync + 'static { + /// List the children of a Drive folder for the folder-picker UI. `None` + /// lists the Drive root. + fn list_children( + &self, + macro_user_id: &str, + folder_id: Option<&str>, + page_token: Option<&str>, + ) -> impl Future> + Send; + + /// Import the selected Drive files/folders into Macro. + fn import( + &self, + macro_user_id: &str, + request: ImportRequest, + ) -> impl Future> + Send; + + /// Whether the user currently has a Drive link row. + fn is_connected( + &self, + macro_user_id: &str, + ) -> impl Future> + Send; +} diff --git a/rust/cloud-storage/google_drive/src/domain/service.rs b/rust/cloud-storage/google_drive/src/domain/service.rs new file mode 100644 index 0000000000..b295969d87 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/service.rs @@ -0,0 +1,284 @@ +//! Concrete [`GoogleDriveService`] implementation. +//! +//! Orchestrates browse + import by composing the [`DriveApi`], +//! [`DriveAccessTokens`], [`GoogleDriveRepo`], and [`DriveImportSink`] ports. +//! Import is performed with an explicit work-stack (rather than async +//! recursion) so parents are always created before their children. + +#[cfg(test)] +mod test; + +use crate::domain::models::{ + DriveFile, DriveFileList, GoogleDriveError, GoogleDriveLink, ImportFileArgs, ImportItem, + ImportRequest, ImportResult, ImportedEntity, ImportedKind, export_target_for, +}; +use crate::domain::ports::{ + AccessTokenError, DriveAccessTokens, DriveApi, DriveImportSink, GoogleDriveRepo, + GoogleDriveService, +}; + +/// Safety cap on the number of entities a single import may create, to bound +/// runaway recursion over very large Drives. Items beyond the cap are skipped. +const MAX_IMPORT_ENTITIES: usize = 5_000; + +/// The special Drive id that refers to the user's root folder. +const DRIVE_ROOT: &str = "root"; + +/// Composes the Drive ports into the high-level browse/import service. +pub struct GoogleDriveServiceImpl { + drive_api: A, + tokens: T, + repo: R, + sink: S, +} + +impl GoogleDriveServiceImpl +where + A: DriveApi, + T: DriveAccessTokens, + R: GoogleDriveRepo, + S: DriveImportSink, +{ + /// Create a new service from its port adapters. + pub fn new(drive_api: A, tokens: T, repo: R, sink: S) -> Self { + Self { + drive_api, + tokens, + repo, + sink, + } + } + + /// Resolve the user's link + a fresh access token, mapping the absence of a + /// link to [`GoogleDriveError::NoLinkFound`] and a revoked refresh token to + /// [`GoogleDriveError::ReauthenticationRequired`]. + async fn resolve_session( + &self, + macro_user_id: &str, + ) -> Result<(GoogleDriveLink, String), GoogleDriveError> { + let link = self + .repo + .get_link_by_user_id(macro_user_id) + .await + .map_err(|e| GoogleDriveError::Internal(e.into()))? + .ok_or(GoogleDriveError::NoLinkFound)?; + + let access_token = self + .tokens + .retrieve_access_token(&link.fusionauth_user_id, &link.email) + .await + .map_err(|e| match e { + AccessTokenError::ReauthenticationRequired => { + GoogleDriveError::ReauthenticationRequired + } + AccessTokenError::Internal(e) => GoogleDriveError::Internal(e), + })?; + + Ok((link, access_token)) + } + + fn drive_err(e: A::Err) -> GoogleDriveError { + GoogleDriveError::Internal(e.into()) + } + + fn sink_err(e: S::Err) -> GoogleDriveError { + GoogleDriveError::Internal(e.into()) + } + + /// Fetch every child of a folder, following pagination. + async fn list_all_children( + &self, + access_token: &str, + folder_id: &str, + ) -> Result, GoogleDriveError> { + let mut all = Vec::new(); + let mut page_token: Option = None; + loop { + let DriveFileList { + files, + next_page_token, + } = self + .drive_api + .list_children(access_token, folder_id, page_token.as_deref()) + .await + .map_err(Self::drive_err)?; + all.extend(files); + match next_page_token { + Some(token) => page_token = Some(token), + None => break, + } + } + Ok(all) + } + + /// Download (or export) a single Drive file and hand it to the sink as a + /// Macro Document. Returns the new document id. + async fn import_single_file( + &self, + macro_user_id: &str, + access_token: &str, + file: &DriveFile, + parent_macro_project_id: Option<&str>, + ) -> Result { + let (content, name) = match export_target_for(&file.mime_type) { + // Google-native doc: export to a concrete format and append the ext. + Some(target) => { + let bytes = self + .drive_api + .export_file(access_token, &file.id, target.export_mime) + .await + .map_err(Self::drive_err)?; + (bytes, format!("{}.{}", file.name, target.extension)) + } + // Regular binary file: download as-is. + None => { + let bytes = self + .drive_api + .download_file(access_token, &file.id) + .await + .map_err(Self::drive_err)?; + (bytes, file.name.clone()) + } + }; + + self.sink + .import_file( + macro_user_id, + ImportFileArgs { + drive_id: file.id.clone(), + name, + mime_type: file.mime_type.clone(), + web_view_link: file.web_view_link.clone(), + parent_macro_project_id: parent_macro_project_id.map(str::to_owned), + content, + }, + ) + .await + .map_err(Self::sink_err) + } +} + +/// A node still to be imported, paired with the Macro project it should land in. +struct PendingNode { + file: DriveFile, + parent_macro_project_id: Option, +} + +impl GoogleDriveService for GoogleDriveServiceImpl +where + A: DriveApi, + T: DriveAccessTokens, + R: GoogleDriveRepo, + S: DriveImportSink, +{ + #[tracing::instrument(skip(self), err)] + async fn list_children( + &self, + macro_user_id: &str, + folder_id: Option<&str>, + page_token: Option<&str>, + ) -> Result { + let (_link, access_token) = self.resolve_session(macro_user_id).await?; + self.drive_api + .list_children(&access_token, folder_id.unwrap_or(DRIVE_ROOT), page_token) + .await + .map_err(Self::drive_err) + } + + #[tracing::instrument(skip(self, request), fields(item_count = request.items.len()), err)] + async fn import( + &self, + macro_user_id: &str, + request: ImportRequest, + ) -> Result { + let (_link, access_token) = self.resolve_session(macro_user_id).await?; + let mut result = ImportResult::default(); + + // Seed the work-stack with the selected items, resolving authoritative + // metadata from Drive (don't trust the client's notion of type). + let mut stack: Vec = Vec::new(); + for ImportItem { drive_id } in request.items { + let file = self + .drive_api + .get_file(&access_token, &drive_id) + .await + .map_err(Self::drive_err)?; + stack.push(PendingNode { + file, + parent_macro_project_id: request.destination_project_id.clone(), + }); + } + + while let Some(PendingNode { + file, + parent_macro_project_id, + }) = stack.pop() + { + if file.trashed { + result.skip(); + continue; + } + if result.imported.len() >= MAX_IMPORT_ENTITIES { + result.skip(); + continue; + } + + if file.is_folder() { + let project_id = self + .sink + .create_folder( + macro_user_id, + &file.name, + parent_macro_project_id.as_deref(), + &file.id, + file.web_view_link.as_deref(), + ) + .await + .map_err(Self::sink_err)?; + + let children = self.list_all_children(&access_token, &file.id).await?; + for child in children { + stack.push(PendingNode { + file: child, + parent_macro_project_id: Some(project_id.clone()), + }); + } + + result.push(ImportedEntity { + drive_id: file.id, + macro_id: project_id, + kind: ImportedKind::Folder, + name: file.name, + }); + } else { + let document_id = self + .import_single_file( + macro_user_id, + &access_token, + &file, + parent_macro_project_id.as_deref(), + ) + .await?; + + result.push(ImportedEntity { + drive_id: file.id, + macro_id: document_id, + kind: ImportedKind::Document, + name: file.name, + }); + } + } + + Ok(result) + } + + #[tracing::instrument(skip(self), err)] + async fn is_connected(&self, macro_user_id: &str) -> Result { + Ok(self + .repo + .get_link_by_user_id(macro_user_id) + .await + .map_err(|e| GoogleDriveError::Internal(e.into()))? + .is_some()) + } +} diff --git a/rust/cloud-storage/google_drive/src/domain/service/test.rs b/rust/cloud-storage/google_drive/src/domain/service/test.rs new file mode 100644 index 0000000000..1601ab1887 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/domain/service/test.rs @@ -0,0 +1,355 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use chrono::Utc; +use uuid::Uuid; + +use super::GoogleDriveServiceImpl; +use crate::domain::models::{ + DriveFile, DriveFileList, FOLDER_MIME_TYPE, GoogleDriveError, GoogleDriveLink, ImportFileArgs, + ImportItem, ImportRequest, ImportedKind, +}; +use crate::domain::ports::{ + AccessTokenError, DriveAccessTokens, DriveApi, DriveImportSink, GoogleDriveRepo, + GoogleDriveService, +}; + +const USER_ID: &str = "macro|user@example.com"; + +fn folder(id: &str, name: &str) -> DriveFile { + DriveFile { + id: id.to_string(), + name: name.to_string(), + mime_type: FOLDER_MIME_TYPE.to_string(), + parents: vec![], + size: None, + modified_time: None, + web_view_link: None, + trashed: false, + } +} + +fn file(id: &str, name: &str, mime: &str) -> DriveFile { + DriveFile { + id: id.to_string(), + name: name.to_string(), + mime_type: mime.to_string(), + parents: vec![], + size: Some("10".to_string()), + modified_time: None, + web_view_link: Some(format!("https://drive.example/{id}")), + trashed: false, + } +} + +fn test_link() -> GoogleDriveLink { + GoogleDriveLink { + id: Uuid::nil(), + macro_id: USER_ID.to_string(), + fusionauth_user_id: Uuid::nil(), + email: "user@example.com".to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + } +} + +#[derive(Default)] +struct StubDriveApi { + files: HashMap, + children: HashMap>, +} + +impl DriveApi for StubDriveApi { + type Err = anyhow::Error; + + async fn list_children( + &self, + _access_token: &str, + folder_id: &str, + _page_token: Option<&str>, + ) -> Result { + Ok(DriveFileList { + files: self.children.get(folder_id).cloned().unwrap_or_default(), + next_page_token: None, + }) + } + + async fn get_file(&self, _access_token: &str, file_id: &str) -> Result { + self.files + .get(file_id) + .cloned() + .ok_or_else(|| anyhow::anyhow!("not found")) + } + + async fn download_file( + &self, + _access_token: &str, + _file_id: &str, + ) -> Result, Self::Err> { + Ok(b"binary-content".to_vec()) + } + + async fn export_file( + &self, + _access_token: &str, + _file_id: &str, + _export_mime: &str, + ) -> Result, Self::Err> { + Ok(b"exported-content".to_vec()) + } +} + +struct StubTokens; + +impl DriveAccessTokens for StubTokens { + async fn retrieve_access_token( + &self, + _fusionauth_user_id: &Uuid, + _email: &str, + ) -> Result { + Ok("access-token".to_string()) + } +} + +#[derive(Clone)] +struct StubRepo { + link: Option, +} + +impl GoogleDriveRepo for StubRepo { + type Err = anyhow::Error; + + async fn get_link_by_user_id( + &self, + _macro_user_id: &str, + ) -> Result, Self::Err> { + Ok(self.link.clone()) + } + + async fn upsert_link(&self, _link: &GoogleDriveLink) -> Result<(), Self::Err> { + Ok(()) + } + + async fn delete_link_by_user_id(&self, _macro_user_id: &str) -> Result<(), Self::Err> { + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq)] +struct FolderCall { + name: String, + parent: Option, + drive_id: String, +} + +#[derive(Clone, Debug, PartialEq)] +struct FileCall { + name: String, + drive_id: String, + mime_type: String, + parent: Option, +} + +#[derive(Clone, Default)] +struct StubSink { + folder_calls: Arc>>, + file_calls: Arc>>, + counter: Arc>, +} + +impl StubSink { + fn next_id(&self, prefix: &str) -> String { + let mut counter = self.counter.lock().unwrap(); + *counter += 1; + format!("{prefix}-{counter}") + } + + fn folder_calls(&self) -> Vec { + self.folder_calls.lock().unwrap().clone() + } + + fn file_calls(&self) -> Vec { + self.file_calls.lock().unwrap().clone() + } +} + +impl DriveImportSink for StubSink { + type Err = anyhow::Error; + + async fn create_folder( + &self, + _macro_user_id: &str, + name: &str, + parent_macro_project_id: Option<&str>, + drive_id: &str, + _web_view_link: Option<&str>, + ) -> Result { + self.folder_calls.lock().unwrap().push(FolderCall { + name: name.to_string(), + parent: parent_macro_project_id.map(str::to_owned), + drive_id: drive_id.to_string(), + }); + Ok(self.next_id("project")) + } + + async fn import_file( + &self, + _macro_user_id: &str, + args: ImportFileArgs, + ) -> Result { + self.file_calls.lock().unwrap().push(FileCall { + name: args.name, + drive_id: args.drive_id, + mime_type: args.mime_type, + parent: args.parent_macro_project_id, + }); + Ok(self.next_id("doc")) + } +} + +fn service( + api: StubDriveApi, + repo: StubRepo, + sink: StubSink, +) -> GoogleDriveServiceImpl { + GoogleDriveServiceImpl::new(api, StubTokens, repo, sink) +} + +#[tokio::test] +async fn imports_folder_tree_creating_parent_before_children() { + let reports = folder("f1", "Reports"); + let report_pdf = file("d1", "q3.pdf", "application/pdf"); + let api = StubDriveApi { + files: HashMap::from([ + ("f1".to_string(), reports), + ("d1".to_string(), report_pdf.clone()), + ]), + children: HashMap::from([("f1".to_string(), vec![report_pdf])]), + }; + let sink = StubSink::default(); + let service = service( + api, + StubRepo { + link: Some(test_link()), + }, + sink.clone(), + ); + + let result = service + .import( + USER_ID, + ImportRequest { + items: vec![ImportItem { + drive_id: "f1".to_string(), + }], + destination_project_id: None, + }, + ) + .await + .expect("import should succeed"); + + assert_eq!(result.imported.len(), 2); + assert_eq!(result.skipped, 0); + + let folder_calls = sink.folder_calls(); + assert_eq!(folder_calls.len(), 1); + assert_eq!(folder_calls[0].name, "Reports"); + assert_eq!(folder_calls[0].parent, None); + assert_eq!(folder_calls[0].drive_id, "f1"); + + // The file is imported into the project created for its parent folder. + let file_calls = sink.file_calls(); + assert_eq!(file_calls.len(), 1); + assert_eq!(file_calls[0].name, "q3.pdf"); + assert_eq!(file_calls[0].parent, Some("project-1".to_string())); + + let folder_entity = result + .imported + .iter() + .find(|e| e.kind == ImportedKind::Folder) + .unwrap(); + assert_eq!(folder_entity.macro_id, "project-1"); +} + +#[tokio::test] +async fn exports_google_native_docs_with_extension() { + let doc = file( + "d1", + "Meeting Notes", + "application/vnd.google-apps.document", + ); + let api = StubDriveApi { + files: HashMap::from([("d1".to_string(), doc)]), + ..StubDriveApi::default() + }; + let sink = StubSink::default(); + let service = service( + api, + StubRepo { + link: Some(test_link()), + }, + sink.clone(), + ); + + service + .import( + USER_ID, + ImportRequest { + items: vec![ImportItem { + drive_id: "d1".to_string(), + }], + destination_project_id: Some("dest".to_string()), + }, + ) + .await + .unwrap(); + + let file_calls = sink.file_calls(); + assert_eq!(file_calls.len(), 1); + // Google Docs export to PDF and land in the chosen destination project. + assert_eq!(file_calls[0].name, "Meeting Notes.pdf"); + assert_eq!(file_calls[0].parent, Some("dest".to_string())); +} + +#[tokio::test] +async fn import_without_link_returns_no_link_found() { + let service = service( + StubDriveApi::default(), + StubRepo { link: None }, + StubSink::default(), + ); + + let err = service + .import( + USER_ID, + ImportRequest { + items: vec![ImportItem { + drive_id: "d1".to_string(), + }], + destination_project_id: None, + }, + ) + .await + .expect_err("should fail without a link"); + + assert!(matches!(err, GoogleDriveError::NoLinkFound)); +} + +#[tokio::test] +async fn is_connected_reflects_repo_state() { + let connected = service( + StubDriveApi::default(), + StubRepo { + link: Some(test_link()), + }, + StubSink::default(), + ); + assert!(connected.is_connected(USER_ID).await.unwrap()); + + let disconnected = service( + StubDriveApi::default(), + StubRepo { link: None }, + StubSink::default(), + ); + assert!(!disconnected.is_connected(USER_ID).await.unwrap()); +} diff --git a/rust/cloud-storage/google_drive/src/inbound.rs b/rust/cloud-storage/google_drive/src/inbound.rs new file mode 100644 index 0000000000..98ae01b997 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/inbound.rs @@ -0,0 +1,3 @@ +//! Inbound HTTP adapters (Axum). + +pub mod google_drive_router; diff --git a/rust/cloud-storage/google_drive/src/inbound/google_drive_router.rs b/rust/cloud-storage/google_drive/src/inbound/google_drive_router.rs new file mode 100644 index 0000000000..aee20d6534 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/inbound/google_drive_router.rs @@ -0,0 +1,189 @@ +//! Axum router exposing Google Drive browse + import endpoints. +//! +//! Mounted by `document_storage_service` under an authenticated, user-context +//! aware path (e.g. `/internal/google-drive`). Routes: +//! - `GET /files` — list the children of a Drive folder (picker). +//! - `POST /import` — import the selected Drive files/folders into Macro. +//! - `GET /connection` — whether the user has connected Drive. + +use std::sync::Arc; + +use axum::{ + Json, Router, + extract::{Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, + routing::{get, post}, +}; +use model_error_response::ErrorResponse; +use model_user::axum_extractor::MacroUserExtractor; +use serde::{Deserialize, Serialize}; + +use crate::domain::models::{DriveFileList, GoogleDriveError, ImportRequest, ImportResult}; +use crate::domain::ports::GoogleDriveService; + +/// Router state holding the Google Drive service. +pub struct GoogleDriveRouterState { + /// The Google Drive service implementation. + pub service: Arc, +} + +// Manual Clone so `S` need not be `Clone` (it lives behind an `Arc`). +impl Clone for GoogleDriveRouterState { + fn clone(&self) -> Self { + Self { + service: self.service.clone(), + } + } +} + +/// Build the Google Drive router. +pub fn google_drive_router(state: GoogleDriveRouterState) -> Router +where + S: GoogleDriveService, + St: Send + Sync + Clone + 'static, +{ + Router::new() + .route("/files", get(list_files_handler::)) + .route("/import", post(import_handler::)) + .route("/connection", get(connection_handler::)) + .with_state(state) +} + +/// Query params for browsing a folder. +#[derive(Debug, Deserialize)] +pub struct ListFilesQuery { + /// The Drive folder id to list. Omitted lists the user's Drive root. + #[serde(default)] + pub parent_id: Option, + /// Opaque pagination cursor from a previous response. + #[serde(default)] + pub page_token: Option, +} + +/// Whether the authenticated user has connected Google Drive. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct DriveConnectionResponse { + /// `true` once the user has a Drive link. + pub connected: bool, +} + +/// List the children of a Drive folder for the picker UI. +#[utoipa::path( + get, + path = "/google-drive/files", + operation_id = "list_google_drive_files", + params( + ("parent_id" = Option, Query, description = "Drive folder id; omitted = root"), + ("page_token" = Option, Query, description = "Pagination cursor"), + ), + responses( + (status = 200, body = DriveFileList), + (status = 412, description = "Drive not connected", body = ErrorResponse), + (status = 428, description = "Reauthentication required", body = ErrorResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(skip(ctx, user), fields(user_id = %user.macro_user_id), err)] +async fn list_files_handler( + State(ctx): State>, + user: MacroUserExtractor, + Query(query): Query, +) -> Result, DriveApiError> { + let macro_user_id = user.macro_user_id.to_string(); + let files = ctx + .service + .list_children( + ¯o_user_id, + query.parent_id.as_deref(), + query.page_token.as_deref(), + ) + .await?; + Ok(Json(files)) +} + +/// Import selected Drive files/folders into Macro. +#[utoipa::path( + post, + path = "/google-drive/import", + operation_id = "import_google_drive", + request_body = ImportRequest, + responses( + (status = 200, body = ImportResult), + (status = 412, description = "Drive not connected", body = ErrorResponse), + (status = 428, description = "Reauthentication required", body = ErrorResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(skip(ctx, user, request), fields(user_id = %user.macro_user_id), err)] +async fn import_handler( + State(ctx): State>, + user: MacroUserExtractor, + Json(request): Json, +) -> Result, DriveApiError> { + let macro_user_id = user.macro_user_id.to_string(); + let result = ctx.service.import(¯o_user_id, request).await?; + Ok(Json(result)) +} + +/// Report whether the user has connected Google Drive. +#[utoipa::path( + get, + path = "/google-drive/connection", + operation_id = "google_drive_connection_status", + responses( + (status = 200, body = DriveConnectionResponse), + (status = 500, body = ErrorResponse), + ) +)] +#[tracing::instrument(skip(ctx, user), fields(user_id = %user.macro_user_id), err)] +async fn connection_handler( + State(ctx): State>, + user: MacroUserExtractor, +) -> Result, DriveApiError> { + let macro_user_id = user.macro_user_id.to_string(); + let connected = ctx.service.is_connected(¯o_user_id).await?; + Ok(Json(DriveConnectionResponse { connected })) +} + +/// Wraps [`GoogleDriveError`] so it can be returned from Axum handlers. +pub struct DriveApiError(GoogleDriveError); + +impl From for DriveApiError { + fn from(error: GoogleDriveError) -> Self { + Self(error) + } +} + +impl IntoResponse for DriveApiError { + fn into_response(self) -> Response { + let (status, message): (StatusCode, &str) = match &self.0 { + GoogleDriveError::NoLinkFound => ( + StatusCode::PRECONDITION_FAILED, + "google drive is not connected", + ), + GoogleDriveError::ReauthenticationRequired => ( + StatusCode::PRECONDITION_REQUIRED, + "google drive reauthentication required", + ), + GoogleDriveError::NotFound => { + (StatusCode::NOT_FOUND, "google drive resource not found") + } + GoogleDriveError::DriveApi(_) => { + (StatusCode::BAD_GATEWAY, "google drive request failed") + } + GoogleDriveError::Internal(error) => { + tracing::error!(error = ?error, "google drive internal error"); + (StatusCode::INTERNAL_SERVER_ERROR, "internal error occurred") + } + }; + + ( + status, + Json(ErrorResponse { + message: message.into(), + }), + ) + .into_response() + } +} diff --git a/rust/cloud-storage/google_drive/src/lib.rs b/rust/cloud-storage/google_drive/src/lib.rs new file mode 100644 index 0000000000..ccceadbcc1 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/lib.rs @@ -0,0 +1,31 @@ +//! Google Drive integration crate. +//! +//! Lets a Macro user connect their Google Drive account (OAuth, handled by +//! `authentication_service`), browse their Drive folder tree, and **import** +//! selected folders/files into Macro as Projects and Documents. This is a +//! one-way import (copy into Macro), not a continuous sync — matching the +//! product requirement of "import folders, but no sync". +//! +//! # Architecture +//! +//! Ports-and-adapters (hexagonal), mirroring the `github` crate: +//! +//! - [`domain`] — domain models, ports (traits), and the service +//! implementations. The import orchestration is generic over a +//! [`domain::ports::DriveImportSink`] so the Macro-storage details +//! (Documents, Projects, S3, foreign-entity mapping) live in the calling +//! service (`document_storage_service`) rather than here. +//! - [`outbound`] — adapters for external dependencies: the Drive REST client +//! (`reqwest`), the access-token client (`authentication_service` + redis), +//! and the `google_drive_links` Postgres repository. +//! - [`inbound`] — Axum handlers exposing browse/import/status endpoints. + +#![deny(missing_docs)] + +pub mod domain; + +#[cfg(any(feature = "db", feature = "http", feature = "tokens"))] +pub mod outbound; + +#[cfg(feature = "inbound")] +pub mod inbound; diff --git a/rust/cloud-storage/google_drive/src/outbound.rs b/rust/cloud-storage/google_drive/src/outbound.rs new file mode 100644 index 0000000000..cfefedfe42 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/outbound.rs @@ -0,0 +1,16 @@ +//! Outbound adapters implementing the domain ports. + +#[cfg(feature = "http")] +mod drive_api_client; +#[cfg(feature = "http")] +pub use drive_api_client::DriveApiClient; + +#[cfg(feature = "tokens")] +mod access_token_client; +#[cfg(feature = "tokens")] +pub use access_token_client::AuthServiceAccessTokens; + +#[cfg(feature = "db")] +mod pg_google_drive_link_repo; +#[cfg(feature = "db")] +pub use pg_google_drive_link_repo::PgGoogleDriveLinkRepo; diff --git a/rust/cloud-storage/google_drive/src/outbound/access_token_client.rs b/rust/cloud-storage/google_drive/src/outbound/access_token_client.rs new file mode 100644 index 0000000000..c64f747d5c --- /dev/null +++ b/rust/cloud-storage/google_drive/src/outbound/access_token_client.rs @@ -0,0 +1,82 @@ +//! [`DriveAccessTokens`] adapter backed by `authentication_service` (which +//! holds the refresh token in FusionAuth) with a short-lived redis cache. + +use std::sync::Arc; + +use authentication_service_client::AuthServiceClient; +use authentication_service_client::error::AuthServiceClientError; +use redis::AsyncCommands; +use redis::aio::MultiplexedConnection; +use uuid::Uuid; + +use crate::domain::ports::{AccessTokenError, DriveAccessTokens}; + +/// Cache lifetime for a Drive access token. Google access tokens live ~1 hour; +/// caching for 30 minutes amortizes the refresh round-trip while leaving ample +/// headroom before expiry. +const TTL_SECONDS: u64 = 60 * 30; + +/// Builds the redis key for a user's cached Drive access token. +macro_rules! drive_access_token_key { + ($fusionauth_user_id:expr) => { + format!("google_drive_access_token:{}", $fusionauth_user_id) + }; +} + +/// Resolves Drive access tokens via `authentication_service`, caching the +/// result in redis per FusionAuth user. +#[derive(Clone)] +pub struct AuthServiceAccessTokens { + auth_client: Arc, + conn: MultiplexedConnection, +} + +impl AuthServiceAccessTokens { + /// Create a new adapter from the auth-service client and a redis connection. + pub fn new(auth_client: Arc, conn: MultiplexedConnection) -> Self { + Self { auth_client, conn } + } +} + +impl DriveAccessTokens for AuthServiceAccessTokens { + #[tracing::instrument(skip(self), err)] + async fn retrieve_access_token( + &self, + fusionauth_user_id: &Uuid, + email: &str, + ) -> Result { + let key = drive_access_token_key!(fusionauth_user_id); + let mut conn = self.conn.clone(); + + if let Some(token) = conn + .get::<&str, Option>(&key) + .await + .map_err(|e| AccessTokenError::Internal(e.into()))? + { + conn.expire::<&str, ()>(&key, TTL_SECONDS as i64) + .await + .map_err(|e| AccessTokenError::Internal(e.into()))?; + return Ok(token); + } + + let token = match self + .auth_client + .get_google_drive_access_token(&fusionauth_user_id.to_string(), email) + .await + { + Ok(token) => token.access_token, + // A revoked/expired refresh token (403) or a missing FusionAuth link + // (404) both mean the user has to reconnect Drive. + Err(AuthServiceClientError::Forbidden | AuthServiceClientError::NotFound) => { + return Err(AccessTokenError::ReauthenticationRequired); + } + Err(e) => return Err(AccessTokenError::Internal(e.into())), + }; + + conn.set_ex::<&str, &str, ()>(&key, &token, TTL_SECONDS) + .await + .map_err(|e| AccessTokenError::Internal(e.into()))?; + + Ok(token) + } +} diff --git a/rust/cloud-storage/google_drive/src/outbound/drive_api_client.rs b/rust/cloud-storage/google_drive/src/outbound/drive_api_client.rs new file mode 100644 index 0000000000..d44e04b33b --- /dev/null +++ b/rust/cloud-storage/google_drive/src/outbound/drive_api_client.rs @@ -0,0 +1,168 @@ +//! [`DriveApi`] adapter backed by the Google Drive REST API v3 over `reqwest`. + +use anyhow::Context; + +use crate::domain::models::{DriveFile, DriveFileList}; +use crate::domain::ports::DriveApi; + +/// Default Drive API v3 base URL. +const DEFAULT_BASE_URL: &str = "https://www.googleapis.com/drive/v3"; + +/// Fields requested for a single file. Keep in sync with [`DriveFile`]. +const FILE_FIELDS: &str = "id,name,mimeType,parents,size,modifiedTime,webViewLink,trashed"; + +/// Page size for `files.list`. Drive caps this at 1000; 200 keeps responses small. +const PAGE_SIZE: &str = "200"; + +/// `reqwest`-backed Google Drive client. +#[derive(Clone)] +pub struct DriveApiClient { + inner: reqwest::Client, + base_url: String, +} + +impl Default for DriveApiClient { + fn default() -> Self { + Self::new() + } +} + +impl DriveApiClient { + /// Create a client targeting the public Drive API. + pub fn new() -> Self { + Self { + inner: reqwest::Client::new(), + base_url: DEFAULT_BASE_URL.to_string(), + } + } + + /// Create a client targeting a custom base URL (for tests / mock servers). + pub fn with_base_url(inner: reqwest::Client, base_url: impl Into) -> Self { + Self { + inner, + base_url: base_url.into(), + } + } + + /// Fetch a URL with a bearer token and download the raw body bytes, used + /// for both direct downloads and exports. + async fn get_bytes( + &self, + access_token: &str, + url: &str, + query: &[(&str, &str)], + ) -> anyhow::Result> { + let response = self + .inner + .get(url) + .bearer_auth(access_token) + .query(query) + .send() + .await + .context("drive request failed")? + .error_for_status() + .context("drive returned an error status")?; + + let bytes = response + .bytes() + .await + .context("failed to read drive response body")?; + Ok(bytes.to_vec()) + } +} + +impl DriveApi for DriveApiClient { + type Err = anyhow::Error; + + #[tracing::instrument(skip(self, access_token), err)] + async fn list_children( + &self, + access_token: &str, + folder_id: &str, + page_token: Option<&str>, + ) -> Result { + // Drive query: direct children of `folder_id`, excluding trashed items. + let q = format!( + "'{}' in parents and trashed = false", + escape_query(folder_id) + ); + let fields = format!("nextPageToken,files({FILE_FIELDS})"); + + let mut query: Vec<(&str, &str)> = vec![ + ("q", q.as_str()), + ("fields", fields.as_str()), + ("pageSize", PAGE_SIZE), + ("supportsAllDrives", "true"), + ("includeItemsFromAllDrives", "true"), + // Order folders first, then by name, for a stable picker experience. + ("orderBy", "folder,name"), + ]; + if let Some(token) = page_token { + query.push(("pageToken", token)); + } + + let response = self + .inner + .get(format!("{}/files", self.base_url)) + .bearer_auth(access_token) + .query(&query) + .send() + .await + .context("drive files.list request failed")? + .error_for_status() + .context("drive files.list returned an error status")?; + + response + .json::() + .await + .context("failed to deserialize drive files.list response") + } + + #[tracing::instrument(skip(self, access_token), err)] + async fn get_file(&self, access_token: &str, file_id: &str) -> Result { + let response = self + .inner + .get(format!("{}/files/{file_id}", self.base_url)) + .bearer_auth(access_token) + .query(&[("fields", FILE_FIELDS), ("supportsAllDrives", "true")]) + .send() + .await + .context("drive files.get request failed")? + .error_for_status() + .context("drive files.get returned an error status")?; + + response + .json::() + .await + .context("failed to deserialize drive files.get response") + } + + #[tracing::instrument(skip(self, access_token), err)] + async fn download_file(&self, access_token: &str, file_id: &str) -> Result, Self::Err> { + let url = format!("{}/files/{file_id}", self.base_url); + self.get_bytes( + access_token, + &url, + &[("alt", "media"), ("supportsAllDrives", "true")], + ) + .await + } + + #[tracing::instrument(skip(self, access_token), err)] + async fn export_file( + &self, + access_token: &str, + file_id: &str, + export_mime: &str, + ) -> Result, Self::Err> { + let url = format!("{}/files/{file_id}/export", self.base_url); + self.get_bytes(access_token, &url, &[("mimeType", export_mime)]) + .await + } +} + +/// Escape a value for safe interpolation into a Drive `q` query string, where +/// `'` is the string delimiter and `\` the escape character. +fn escape_query(value: &str) -> String { + value.replace('\\', "\\\\").replace('\'', "\\'") +} diff --git a/rust/cloud-storage/google_drive/src/outbound/pg_google_drive_link_repo.rs b/rust/cloud-storage/google_drive/src/outbound/pg_google_drive_link_repo.rs new file mode 100644 index 0000000000..a5ea6b8dd6 --- /dev/null +++ b/rust/cloud-storage/google_drive/src/outbound/pg_google_drive_link_repo.rs @@ -0,0 +1,75 @@ +//! PostgreSQL implementation of the [`GoogleDriveRepo`] port, backed by the +//! `google_drive_links` table. + +use sqlx::PgPool; + +use crate::domain::models::GoogleDriveLink; +use crate::domain::ports::GoogleDriveRepo; + +/// PostgreSQL-backed Google Drive link repository. +#[derive(Clone)] +pub struct PgGoogleDriveLinkRepo { + pool: PgPool, +} + +impl PgGoogleDriveLinkRepo { + /// Create a new repository backed by the given connection pool. + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +impl GoogleDriveRepo for PgGoogleDriveLinkRepo { + type Err = sqlx::Error; + + #[tracing::instrument(skip(self), err)] + async fn get_link_by_user_id( + &self, + macro_user_id: &str, + ) -> Result, Self::Err> { + sqlx::query_as!( + GoogleDriveLink, + r#" + SELECT id, macro_id, fusionauth_user_id, email, created_at, updated_at + FROM google_drive_links + WHERE macro_id = $1 + "#, + macro_user_id + ) + .fetch_optional(&self.pool) + .await + } + + #[tracing::instrument(skip(self), err)] + async fn upsert_link(&self, link: &GoogleDriveLink) -> Result<(), Self::Err> { + sqlx::query!( + r#" + INSERT INTO google_drive_links (id, macro_id, fusionauth_user_id, email) + VALUES ($1, $2, $3, $4) + ON CONFLICT (macro_id) + DO UPDATE SET + fusionauth_user_id = EXCLUDED.fusionauth_user_id, + email = EXCLUDED.email, + updated_at = NOW() + "#, + link.id, + link.macro_id, + link.fusionauth_user_id, + link.email, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + #[tracing::instrument(skip(self), err)] + async fn delete_link_by_user_id(&self, macro_user_id: &str) -> Result<(), Self::Err> { + sqlx::query!( + "DELETE FROM google_drive_links WHERE macro_id = $1", + macro_user_id + ) + .execute(&self.pool) + .await?; + Ok(()) + } +} diff --git a/rust/cloud-storage/macro_db_client/migrations/20260609170742_create_google_drive_links.sql b/rust/cloud-storage/macro_db_client/migrations/20260609170742_create_google_drive_links.sql new file mode 100644 index 0000000000..6983e7fa89 --- /dev/null +++ b/rust/cloud-storage/macro_db_client/migrations/20260609170742_create_google_drive_links.sql @@ -0,0 +1,19 @@ +-- Persists a Macro user's Google Drive connection. +-- +-- The OAuth refresh token itself lives in FusionAuth (the identity-provider +-- link); this table only records enough to know a user is connected and to +-- resolve the Drive account email that the access-token endpoint keys off. +CREATE TABLE IF NOT EXISTS google_drive_links +( + id UUID PRIMARY KEY NOT NULL, + macro_id TEXT NOT NULL, + fusionauth_user_id UUID NOT NULL, + email TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + -- One Drive link per Macro user (supports upsert ON CONFLICT (macro_id)). + CONSTRAINT google_drive_links_macro_id_key UNIQUE (macro_id) +); + +CREATE INDEX IF NOT EXISTS idx_google_drive_links_fusionauth_user_id + ON google_drive_links (fusionauth_user_id);