# OneDrive and Google Drive as storage targets, 2026-09-10

Owner instruction, 2026-09-10: "add gdrive and onedrive now". Both providers are live beside
Nextcloud. The design is the one discovery note 09 already wrote; this record says what was built,
what the owner must register before either can be connected, and the one rule that was decided
on the way.

## What was built

**Connection by consent, not by password.** An organization administrator saves a connection with a
name only, then presses **Connect** on its row. The web host sends them to Microsoft or Google
with a state bound to their tenant and their person (threat B5.2), a PKCE challenge and only the
scopes the design allows: `Files.ReadWrite` + `offline_access` for OneDrive for Business,
`drive.file` for Google Drive (the non-sensitive scope that needs no CASA assessment). The
callback refuses any state that is not theirs, redeems the code, and stores the refresh token as
the connection's credential in CREDST (a dictionary payload, the shape the Apollo channels use).
The connection is tested at once and the row shows the account it answers for. Personal OneDrive
accounts are refused at the test (D-48).

**Transfers.** The worker now takes every registered provider and picks the connection by one
rule: the oldest enabled connection that has a credential, among the providers the worker
implements. The storage page shows which row that is (**Active target**) and lets the admin
enable and disable rows; to move a licence from Nextcloud to a drive, enable the drive and
disable Nextcloud. Nothing fans out to more than one target.

- OneDrive: folders created one segment at a time with `conflictBehavior=fail` (409 means
  already there); files under 4 MiB in one PUT with `conflictBehavior=replace` under the
  document's own folder, larger ones through an upload session in 10 MiB fragments (a multiple
  of 320 KiB) without a bearer on the fragments; 429/503 honour `Retry-After`, capped at 30 s and
  three retries, then the job's own backoff takes over. The User-Agent is `ISV|GOTT|Sibyla/1.0`.
- Google Drive: every folder looked up by name under its parent (oldest match adopted, so racing
  retries converge) before it is created; a file already in the document's folder is adopted;
  otherwise a resumable upload in 8 MiB chunks (a multiple of 256 KiB) with 308 between them.
  Drive sends no reliable `Retry-After`, so throttling backs off 1, 2, 4 s with jitter.
- Both refresh their access token on every operation and never store it. Entra rotates the
  refresh token on use; the new one is stored as a fresh CREDST row with the old one revoked,
  from the web host after a test and from the worker after a transfer, so the 90-day rolling
  window keeps rolling. A revoked or expired grant sets the connection to **Waiting for
  credentials** with a message that says to reconnect.

**Schema.** `docint.remote_object_id` (migration `20260910042518_IntakeRemoteObjectId`, applied
to Preview on 2026-09-10; Main at the next release) records the provider's own id of the stored
object, because a Drive file is addressed by id, not by path. `remote_path` and the
`doclog.archive_path` propagation are unchanged for every provider.

**The uploads page** names the provider that holds each transferred document instead of a fixed
"Nextcloud".

## What the owner must register before Connect works

The platform's own OAuth clients are platform configuration, never tenant data (09 §8.1). They
go in the `Storage` section of **both** hosts' `SIBYLA_SECRETS_FILE`: the web host runs the
consent, the worker refreshes tokens. Until they exist, the storage page shows the provider as
"client not configured" and Connect lands back on the page with that message.

```json
"Storage": {
  "OneDrive":    { "ClientId": "<Entra application (client) id>", "ClientSecret": "<client secret>" },
  "GoogleDrive": { "ClientId": "<OAuth client id>.apps.googleusercontent.com", "ClientSecret": "<client secret>" },
  "PublicBaseUrl": "https://sibyla.gottsolutions.net"
}
```

1. **Entra ID**: register one application, accounts in any organizational directory
   (multi-tenant; personal accounts excluded), web platform, redirect URI
   `https://sibyla.gottsolutions.net/storage/oauth/onedrive`; delegated permissions
   `Files.ReadWrite`, `User.Read`, `offline_access`, `openid`, `profile`, `email`; a client
   secret with the longest allowed life, and a calendar note for its expiry.
2. **Google Cloud**: a project with the Drive API enabled, an OAuth consent screen (external,
   scope `.../auth/drive.file` plus `openid`, `email`), a web-application OAuth client with redirect
   URI `https://sibyla.gottsolutions.net/storage/oauth/gdrive`. While the consent screen is in
   testing, only listed test users can connect; publishing needs Google's brand verification, which
   09 §10 says to start early because of its lead time.
3. Restart the web host and the worker so the secrets are read.

`PublicBaseUrl` is optional; without it the callback is built from the request's scheme and host.

## Tests

- `OneDriveStorageProviderTests`, `GoogleDriveStorageProviderTests`: the wire sequences replayed
  through a scripted handler (`StorageProviderTestSupport`), including rotation, the personal-drive
  refusal, folder creation, both upload paths, throttling with and without `Retry-After`, the
  authorize URL and the id-token claims.
- `StorageOAuthStateTests`: the state is bound to tenant and person, expires, is per provider,
  and cannot be read or forged outside the key ring.
- `StorageTransferProcessorTests` (Preview): the selection rule, the credential shape each
  provider gets, the object id in the result, the rotation as a new CREDST row.
- The storage page keeps its render and localization coverage; the worker host still composes.

No live call to either provider was made: there is no client registered yet, and a test against
a real drive is an opt-in the owner runs after registering one.

## Not done, on purpose

- No share links; no provider owns a link operation and nothing needs one.
- No provider-side immutability lock (09 open question 7); originals are never overwritten by
  the application and that remains the rule.
- No STGOBJ manifest table; `remote_object_id` on the intake row is enough for addressing, and
  the manifest can be added if a storage audit ever needs it.
- The app-only `Sites.Selected` option for SharePoint libraries is not built; the delegated
  connection is the first of the two 09 §2.1 named.
