Skip to content

feat(oauth): request read-only Gmail access with --readonly - #594

Open
wesm wants to merge 2 commits into
mainfrom
feat/add-account-readonly-hardened
Open

feat(oauth): request read-only Gmail access with --readonly#594
wesm wants to merge 2 commits into
mainfrom
feat/add-account-readonly-hardened

Conversation

@wesm

@wesm wesm commented Aug 10, 2026

Copy link
Copy Markdown
Member

Adds a --readonly flag to add-account that requests Gmail read access (gmail.readonly) instead of the default read + modify pair.

Reconciliation of #567: quetza's latest revision (adopted verbatim as the base commit — its live-account testing established that access already granted cannot be narrowed in place), plus one hardening commit on top.

What changed

  • add-account --readonly authorizes an account with read-only Gmail access. Sync, search, and the TUI work as normal; deletion prompts to re-authorize with write access.
  • Access already granted cannot be narrowed: re-authorizing does not revoke the prior grant, and revocation is grant-wide, so --readonly refuses an account that already holds write access (--force included) and prints the procedure that works — revoke msgvault at myaccount.google.com/permissions, delete the token file, re-add with --readonly.
  • A token from a provably different OAuth client is a fresh grant and is not refused.
  • After a --readonly authorization, the granted scopes are checked: if Google returned write access anyway, a warning names it and the removal procedure.
  • Any Gmail scope not on a read-only allow-list counts as write access, so scopes msgvault never requests (e.g. gmail.send in an externally minted token) can't pass as narrow.
  • The --readonly decision resolves Gmail alias spellings (dots, plus-addresses, case, googlemail.com) against stored tokens, so a second spelling can't read as a fresh account while the stored spelling keeps its access.
  • remove-account revokes the Gmail grant (best-effort) before deleting the token file, so removing an account and re-adding it read-only yields a genuinely fresh grant.
  • Calendar and Drive grants are carried forward on every re-authorization, and re-auth paths (expired tokens, add-calendar) no longer silently restore write access to a narrowed account.
  • Recovery hints and headless instructions keep --readonly, so following them doesn't undo the narrowing.
  • The deletion pre-flight accepts mail.google.com as sufficient for trashing; it previously demanded gmail.modify even from accounts with broader access.

Why

Sync only reads mail, but add-account always requested gmail.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

msgvault add-account you@gmail.com --readonly

Default behavior without --readonly is unchanged.

Limitations

  • Access already granted cannot be narrowed in place (verified against a live Google account); the revoke-and-re-add procedure is printed by the refusal and documented in the setup guide.
  • --readonly is rejected for service accounts; their scopes come from the domain-wide delegation grant in the Admin Console.

Also expands the Google Cloud Console setup walkthrough in docs/guides/oauth-setup.md.

🤖 Generated with Claude Code

## 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, since that access cannot be narrowed in place, and point at the revoke-and-re-add procedure that works
- refuse `--readonly` against a token that predates scope recording, since its grant cannot be verified and tokens that old were minted with read + modify
- carry non-Gmail grants forward on every re-authorization, so Calendar and Drive are never dropped by a Gmail scope change
- 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.

Access already granted cannot be narrowed, and this was measured rather than assumed. Re-authorizing with a smaller scope set gives msgvault a narrower token, but the previous grant stays live: a refresh token issued beforehand still returns access tokens carrying `gmail.modify`. Revoking that credential is not a fix either, because revocation applies to the whole grant and takes the replacement with it. Both were confirmed against a live Google account.

So `--readonly` refuses an account that already holds write access instead of performing a change that would look like narrowing without being one. The way to make such an account read-only is to revoke at Google and grant again, which is documented in the setup guide and named in the refusal message.

The deletion pre-flight change matters only once accounts can be read-only. A read-only account later escalated for permanent deletion holds `mail.google.com` without `gmail.modify`, which the old check read as insufficient for trashing even though full access covers it.

## Usage

```console
msgvault add-account you@gmail.com --readonly
```

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

- access already granted cannot be narrowed. A refresh token issued before a narrowing attempt keeps working with its original scopes, and revocation applies to the whole grant, so revoking it would invalidate the replacement too — both verified against a live account. `--readonly` therefore refuses an account that already holds Gmail write access, `--force` included; the documented procedure is to revoke at Google and grant again. `--readonly` on a new account is unaffected
- `--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-ci

roborev-ci Bot commented Aug 10, 2026

Copy link
Copy Markdown

roborev: Combined Review (54a6282)

OAuth credential lifecycle handling has three Medium-severity issues involving equivalent Gmail addresses and failed reauthorization.

Medium

  • cmd/msgvault/cmd/addaccount.go:695 — Forced scope narrowing retires every token for equivalent Gmail spellings but stores the replacement only under the selected spelling. Other registered equivalent sources are left without usable credentials.

    • Fix: Refuse narrowing until duplicate sources are reconciled, or associate the new credential with every equivalent source that remains registered.
  • cmd/msgvault/cmd/addaccount.go:932 — A non-read-only --force run deletes the existing token before authorization succeeds. If authorization fails, scope metadata is lost, so a later attempt may request only default Gmail scopes and inadvertently drop existing Calendar or Drive grants.

    • Fix: Retain the existing token until authorization succeeds, or persist its scopes in a credential-free pending record across retries.
  • cmd/msgvault/cmd/remove_account.go:281 — Removing a Gmail account revokes and deletes only the token for the exact identifier. Credentials under equivalent dot, plus-address, or googlemail.com spellings may remain stored and authorized despite no longer being referenced.

    • Fix: Find equivalent token files and retire those unused by another registered source; warn or refuse removal when their lifecycle cannot be resolved safely.

Reviewers: 2 done | Synthesis: codex, 10s | Total: 6m7s

…g bypass

Two hardening pieces layered on the no-narrowing model, which live
testing established: re-authorizing does not revoke a prior grant, and
revocation is grant-wide, so --readonly refuses write-capable accounts
and the remedy is revoke-at-Google and re-add.

remove-account now revokes the Gmail grant (best-effort, matching the
Microsoft path) before deleting the token file. Removal previously
deleted only the local file, so copies of the refresh token outlived
the account — and a later re-add with --readonly reported a fresh
read-only setup while the old grant stayed live. Revocation needs no
OAuth client configuration, so it works even without client secrets;
a missing or already-dead credential is silently fine.

The --readonly grant decision now resolves Gmail alias spellings
(dots, plus-addresses, case, googlemail.com) against stored tokens.
Authorization accepts those variants as the same account, so a run
under an alias of a stored token read as a fresh account while the
stored spelling's credential kept its access. With no token under the
requested spelling the refusal points at the stored one; with tokens
under both, it names the revoke-and-re-add procedure with every
duplicate file removed, since account-level revocation retires both
credentials together.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@wesm
wesm force-pushed the feat/add-account-readonly-hardened branch from 54a6282 to 004dab3 Compare August 10, 2026 19:36
@roborev-ci

roborev-ci Bot commented Aug 10, 2026

Copy link
Copy Markdown

roborev: Combined Review (004dab3)

Verdict: Two medium-severity OAuth grant lifecycle issues require attention.

Medium

  • Missing token metadata can falsely imply read-only accesscmd/msgvault/cmd/addaccount.go:696
    A missing or unreadable token is always treated as a new grant. For an existing Gmail source, a previous write-capable Google grant may still be active, so --readonly can report success without narrowing access. Pass existing-source/client-binding state into the decision and fail closed for same-client accounts without verifiable token metadata, requiring revocation before read-only reauthorization.

  • Removing an account can revoke credentials used by an aliascmd/msgvault/cmd/remove_account.go:281
    Revocation occurs unconditionally, even when another Gmail source uses an equivalent alias for the same account and OAuth client. Because Google revocation is grant-wide, removing one alias can invalidate the remaining account. Detect equivalent remaining Gmail sources using the same client and revoke only when the last source is removed, or remove all aliases as one explicit operation.


Reviewers: 2 done | Synthesis: codex, 11s | Total: 9m9s

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants