feat(oauth): request read-only Gmail access with --readonly - #567
Conversation
## What changed - add `--readonly` to `add-account`, requesting `gmail.readonly` alone instead of the default read + modify pair - treat Gmail write access as a set, `gmail.modify` and `mail.google.com`, so a check no longer misses an account holding only the broader scope - refuse `--readonly` against an account that already holds write access, pointing at `--force`, rather than reporting success while changing nothing - preserve non-Gmail grants across narrowing, so Calendar and Drive survive `--readonly --force` - warn before a plain `add-account` re-widens an account that currently has no write access - stop sync's expired-token re-auth and `add-calendar` from silently unioning write scopes back into a narrowed grant - accept `mail.google.com` in the trash deletion pre-flight, which previously demanded `gmail.modify` and prompted accounts that already exceeded it ## Why Sync only reads mail, but `add-account` has always requested `gmail.modify` alongside `gmail.readonly`, so there was no way to run msgvault against Gmail with a read-only grant. Restricting scopes on the Cloud Console's Data Access page does not achieve this. That page is a declaration used for verification review, not a request-time restriction and the authorization server grants whatever the client requests. Declining a scope at the consent screen fails too, because `authorize` rejects a token that comes back narrower than requested. Changing what the client requests is the only lever. Re-authorization deliberately carries granted scopes forward so replacement consent cannot drop Calendar or Drive from the shared token file. That is also why an existing grant cannot be narrowed in place: without `--force` the stored token is reused and no narrower request is ever sent. `--force` deletes the token, which makes the narrower request real and narrowing subtracts only the write scopes from what is carried forward. The deletion pre-flight change matters only once accounts can be read-only. Narrowing an account and later escalating it for permanent deletion yields `mail.google.com` without `gmail.modify`, which the old check read as insufficient for trashing. ## Usage ```console msgvault add-account you@gmail.com --readonly msgvault add-account you@gmail.com --readonly --force # narrow an existing grant ``` Default behaviour is unchanged. Nothing is persisted about how an account was set up, the no-op and warning behaviours derive from its current grant. ## Limitations - narrowing an existing grant needs `--force`, which needs a browser so cannot be done with only a headless host - `--readonly` is rejected for service accounts whose scope comes from the domain-wide delegation grant in the Admin Console rather than from msgvault flags Also expands the Google Cloud Console setup walkthrough in `docs/guides/oauth-setup.md`. Co-Authored-By: Claude Opus 5
roborev: Combined Review (
|
A token predating scope recording carries no scopes array, so the write-access check found nothing to object to, the token was reused, and `add-account --readonly` reported success over an account that still held `gmail.modify`. Tokens that old were minted when read + modify was the only scope set, so "no recorded scopes" has to mean "possibly write-capable" rather than "already narrow". Such tokens are now refused under `--readonly` unless `--force` is supplied, with instructions to reauthorize for an explicitly recorded read-only grant. The token-reuse check refuses them too, so an unverifiable grant cannot be reused as though it were narrow by either route. Only `--readonly` is affected; default runs keep their existing tolerance for legacy tokens. Also corrects the service-account setup guidance. It claimed a delegation grant limited to `gmail.readonly` was sufficient for a read-only archive, but the Gmail service-account paths request `gmail.readonly` and `gmail.modify` together, so such a grant fails the token exchange and breaks `add-account`, `sync`, `serve`, and `verify`. Narrowing what those paths request is noted as a possible future change rather than attempted here, since it cannot be verified without a Workspace domain configured for domain-wide delegation. Co-Authored-By: Claude Opus 5
roborev: Combined Review (
|
|
will review thank you |
roborev: Combined Review (
|
roborev: Combined Review (
|
## What changed - repeat `--readonly` in the account-mismatch hint printed by `add-account`, so following it does not request write access on the retry - carry the account's recorded grant into the expired-token reauthorization hint, which suggested `add-account <addr> --force` and would have re-widened a narrowed account - carry it into the alias-mismatch remove-and-re-add recipe as well - document that narrowing replaces msgvault's stored token without revoking the previous authorization at Google ## Why Remediation text is followed at the moment an operator is least likely to scrutinise it: something has already failed and they are copying a suggested command to fix it. Guidance that silently widens a grant is worse than no guidance at all. The reauthorization hint reads the recorded grant through an optional interface, before any reauthorization replaces it. A manager that cannot report scopes, or a grant with no Gmail scopes at all, counts as not narrowed, so every case predating `--readonly` keeps its existing text unchanged. `--force` deletes msgvault's stored token and obtains a narrower one. It does not ask Google to revoke the old authorization, so a copy of the previous token file keeps whatever it was granted. Revoking through Google clears every scope for the account, Calendar and Drive included, so revocation cannot be folded into narrowing without destroying the grants narrowing exists to preserve. Operators are pointed at the revocation page instead. Revoking automatically after a successful narrowing would avoid that, and is left as a possible follow-up rather than attempted here, where a failed reauthorization would leave the account with no access at all. Co-Authored-By: Claude Opus 5
8ffd30b to
e1e01fe
Compare
roborev: Combined Review (
|
|
I can take the wheel from here to get this merge-ready, thank you |
Reapplies the revocation and scope-hardening work on top of the remediation-guidance rework, which had replaced it. Narrowing with --readonly --force now revokes the existing grant at Google before deleting the local token, when that grant is write-capable or has no recorded scopes. Deleting only the file leaves the refresh token valid server-side, so copies of it kept write access while the command reported a successful narrowing. Revocation failure aborts with nothing changed. A grant already verified as read-only skips revocation, so replacing an expired read-only token does not depend on a dead credential being revocable. When msgvault cannot revoke anything itself — the credential is already invalid (revoking it proves nothing about other tokens under the grant), or a registered account has no local token — the operator must confirm having revoked msgvault's access in their Google account settings; EOF or anything but yes aborts. Treat any Gmail scope other than gmail.readonly as write-capable. The fixed modify/full list missed mutation scopes msgvault never requests (gmail.send, gmail.compose, ...) that can appear in tokens minted outside msgvault; detection is now prefix-based. Apply the grant decision in the --headless branch before printing instructions, so a plain headless run over a narrowed account warns instead of printing silently-widening instructions, and --headless --readonly over a write-capable account is refused. The read-only headless instructions copy any existing token to the browser machine first (preserving Calendar/Drive), authorize there with --readonly --force — a copied stale token would otherwise be reused unvalidated — and register on the server without --force. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roborev: Combined Review (
|
…retries Headless read-only runs no longer refuse a write-capable account. --headless and --force are mutually exclusive, so the refusal pointing at --force made the documented copy-and-revoke workflow unreachable. The instructions now print with a note explaining that the browser-machine --readonly --force step revokes the grant. A registered account with no local token gets a warning to revoke msgvault's access at Google before authorizing — printed rather than gated, because the headless command performs no authorization to guard and the daemon-routed subprocess has no stdin for a prompt. Narrowing no longer deletes the token file before authorizing. It revokes the grant, rewrites the stored scope metadata to the narrowed set, and lets the successful authorization replace the file. Deleting first meant a failed or cancelled authorization left no scope record, so a retry requested bare gmail.readonly and silently dropped Calendar/Drive. Token reuse is now gated on --force in the daemon preflight, matching the local path, so the kept (revoked) token is never reported as already authorized. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roborev: Combined Review (
|
Fail closed when narrowing an email spelling that has no stored token but is a Gmail alias of one that does. Authorization accepts case, dot, plus-address, and googlemail.com variants as the same account, so narrowing through an alias minted a read-only token while the stored spelling kept an unrevoked write-capable credential. The error points at the stored spelling to re-run against. Mark the credential a narrowing run revokes as pending re-authorization. It is kept for its scope metadata, but nothing may use it as a live token: TokenSource refuses it — a cached access token could otherwise defer the failure to a later API call — and add-account no longer reports it as already authorized, proceeding to a fresh authorization instead. A successful authorization replaces the file and clears the mark. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roborev: Combined Review (
|
…s paths Move the alias-equivalence check from the forced-reauth preparation into the grant decision, so every read-only path hits it: a plain or headless --readonly under a dot, plus, or googlemail variant of a stored token could still mint or reuse a read-only token while the stored spelling's credential kept its access, and the same held when exact and alias token files coexisted. Any --readonly run whose spelling is a Gmail alias of a stored token is now refused — with or without --force, browser or headless — and pointed at the stored spelling. The decision inputs move into a struct, and FindEquivalentTokenEmail gains an os.SameFile guard so case variants that resolve to the account's own file on case-insensitive filesystems are not misread as a second credential. Print browser-machine paths in the headless instructions as ~/.msgvault/tokens rather than reusing this server's absolute tokens directory. A different user or MSGVAULT_HOME on the browser machine put the Step 0 copy where its msgvault never looks, silently skipping scope preservation and revocation; ~ now expands locally, with a note for custom homes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roborev: Combined Review (
|
… all headless flows Require the manual-revocation confirmation whenever --readonly --force finds no local token, instead of gating it on an exactly-matching registered source. That gate could be bypassed by removing the account before narrowing, or by a source registered under a Gmail alias spelling, while credentials issued earlier stayed valid at Google. A forced narrowing of an account with no token is a rare, deliberate invocation, so the unconditional confirmation costs the common paths nothing. Print the copy-existing-token step and use browser-side --force in every headless flow, not just --readonly. A default headless re-authorization had the browser machine authorize with no recorded scopes to preserve, and Google's replacement consent then dropped Calendar/Drive from the grant — the same hazard the add-calendar headless instructions already guard against. With the copied token, --force rebuilds the request from the recorded scopes; without a token to copy, it changes nothing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
roborev: Combined Review (
|
…token gaps Revoke the stored credential on --readonly --force regardless of its recorded scopes. Skipping verified read-only grants contradicted the documented contract — copies of the old refresh token stayed valid — and a failed authorization left the old token reusable as already authorized. The only skip is a record already marked pending re-authorization, which is only ever set after a successful or operator-confirmed revocation, so retries proceed without re-revoking or prompting. Record gmail.readonly on the pending record of a legacy token. With no scopes at all, an interrupted narrowing could not be retried cleanly: a plain --readonly retry bounced off the legacy refusal and --force tried to revoke the dead credential again. Give duplicate alias token files a cleanup path instead of a deadlock. When both spellings hold tokens, each previously refused by pointing at the other; the refusal now names the remove-account cleanup. Refuse plain --readonly on a registered account whose token file is missing. It was treated as a fresh account and minted a new read-only token while credentials issued earlier may have stayed valid; the refusal points at --readonly --force, which routes through the manual-revocation confirmation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a
--readonlyflag toadd-accountthat requests Gmail read access (gmail.readonly) instead of the default read + modify pair.What changed
add-account --readonlyauthorizes an account with read-only Gmail access. Sync, search, and the TUI work as normal; deletion prompts to re-authorize with write access.--readonlyalone fails and points at--readonly --force, which revokes the old grant at Google, deletes the stored token, and re-authorizes with the narrower scopes. Revoking at Google means copies of the old token (backups, other machines) lose write access too.gmail.readonlycounts as write access, including scopes msgvault never requests that could appear in a token created by another tool.add-calendar) no longer quietly restore write access to a narrowed account.--readonly, so following them doesn't undo the narrowing. A plainadd-accounton a read-only account warns before requesting write access.mail.google.comas sufficient for trashing; it previously demandedgmail.modifyeven from accounts with broader access.Why
Sync only reads mail, but
add-accountalways requestedgmail.modify, so there was no way to run msgvault with a read-only grant. Restricting scopes in the Google Cloud Console doesn't help — that page is a declaration for verification review, not a limit on what gets granted — so changing what the client requests is the only lever.Usage
Default behavior without
--readonlyis unchanged.Limitations
--readonlyis rejected for service accounts; their scopes come from the domain-wide delegation grant in the Admin Console.--headless --readonlyprints steps to narrow on a browser machine and copy the token across.Also expands the Google Cloud Console setup walkthrough in
docs/guides/oauth-setup.md.