8000 fix(google): sign in with the scope a Workspace admin delegated, and say which scope is missing by adeelahmad · Pull Request #704 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/google-delegation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
- **Google service accounts that act as someone sign in with the scope the admin delegated.** Google matches a
domain-wide delegation's scopes exactly, so a client delegated all of Gmail (`https://mail.google.com/`, as backup
tools ask for) was refused `gmail.readonly` and `gmail.compose`, and the source said only "domain-wide delegation"
with the fix cut off. Lens now signs in with the broader scope the admin gave (all of Gmail, Calendar or Drive) and
still calls only what the narrower scope allows. When Google refuses, the source's error names the scopes the
delegation lacks, the ones it allows and the exact list to paste in the Admin console, in full
([Gmail](docs/processing.md#gmail-and-google-workspace)).
< 10BC8 /div>
8 changes: 6 additions & 2 deletions docs/processing.md
Original file line number Diff line number Diff line change
Expand Up @@ -854,8 +854,12 @@ with nothing to set up at Google. A Gmail source signs in one of two ways:
writes one; enter the account's address as `user` to read over IMAP.
- **A service account** (Google Workspace). Upload the service account's JSON key and enter the address of the person
whose mail Lens reads; nobody signs in. A Workspace admin lets the account's client id use
`https://www.googleapis.com/auth/gmail.readonly` (or `https://mail.google.com/` to read over IMAP) under Admin
console › Security › API controls › Domain-wide delegation. One source reads one person's mailbox.
`https://www.googleapis.com/auth/gmail.readonly` (plus `https://www.googleapis.com/auth/gmail.compose` when the
connection may send; `https://mail.google.com/` to read over IMAP) under Admin console › Security › Access and data
control › API controls › Manage Domain Wide Delegation. Google matches those scopes exactly, so a client already
delegated all of Gmail (`https://mail.google.com/`, as backup tools ask for) is used with that scope instead. When
Google refuses, the source's error names the scopes the delegation lacks, the ones it allows and the exact list to
paste. One source reads one person's mailbox.

Labels are the folders: the inbox, sent, starred, important, the inbox's categories (Primary, Social, Promotions,
Updates, Forums) and your own labels. A message is a file `<label id>/<message id>.eml`, read as Gmail keeps it, so it
Expand Down
124 changes: 89 additions & 35 deletions fastapi_backend/app/domain/google_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,21 +126,50 @@ def _cache_key(creds, scopes):
return hashlib.sha256(f"{who_}|{' '.join(sorted(scopes))}".encode()).hexdigest()


def _refused(r, creds, scopes=()):
# A broader scope that covers one Lens asks for. Google matches a domain-wide delegation's scopes exactly, so a client an
# admin gave all of Gmail (as backup tools ask for) is still refused gmail.readonly: a service account acting as someone
# signs in with the broader scope the admin gave instead. Lens calls only what the narrower scope allows either way.
COVERS = {
"https://www.googleapis.com/auth/gmail.readonly": "https://mail.google.com/",
"https://www.googleapis.com/auth/gmail.compose": "https://mail.google.com/",
"https://www.googleapis.com/auth/calendar.readonly": "https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/calendar.events": "https://www.googleapis.com/auth/calendar",
"https://www.googleapis.com/auth/drive.readonly": "https://www.googleapis.com/auth/drive",
}
DELEGATION = "admin.google.com › Security › Access and data control › API controls › Manage Domain Wide Delegation"


def _said(r):
"""Google's error code and its words, from a token endpoint's refusal."""
try:
body = r.json()
except ValueError:
body = {}
err = body.get("error") if isinstance(body, dict) else None
said = (body.get("error_description") if isinstance(body, dict) else None) or err or f"HTTP {r.status_code}"
return err, said


def _not_delegated(creds, scopes, delegated, said):
"""What to change in the Admin console when Google won't let a service account act as someone: the scopes it
refused, the ones it allowed, and the exact list to paste."""
k = creds["key"]
refused = [s for s in scopes if s not in delegated] or list(scopes)
allowed = f"; it allows {', '.join(sorted(delegated))}" if delegated else "; it allows none of them"
return GoogleError(
f"Google refused to let {k['client_email']} act as {creds['subject']}: its domain-wide delegation "
f"doesn't include {', '.join(refused)}{allowed}. In {DELEGATION}, add or edit client id "
f"{k.get('client_id') or '(client_id in the key file)'} and paste these OAuth scopes: {','.join(scopes)}. "
f"Google can take a few minutes, sometimes hours, to apply a change. Google said: {said}"
)


def _refused(r, creds, scopes=()):
err, said = _said(r)
if creds["kind"] == "service_account" and creds["subject"]:
k = creds["key"]
if err == "unauthorized_client":
return GoogleError(
f"Google refused to let {k['client_email']} act as {creds['subject']}: a Workspace admin gives client id "
f"{k.get('client_id') or '(in the key file)'} domain-wide delegation for {','.join(scopes) or 'its scopes'} "
"(Admin console › Security › API controls)"
)
return _not_delegated(creds, scopes, (), said)
if err == "invalid_grant" and "email" in said.lower():
return GoogleError(f"{k['client_email']} can't act as {creds['subject']}: use the address of someone in your Workspace domain")
if creds["kind"] == "authorized_user" and err == "invalid_grant":
Expand All @@ -152,12 +181,7 @@ def _refused(r, creds, scopes=()):
return GoogleError(f"Google refused to sign in: {said}")


def access_token(cfg, creds, scopes, fresh=False):
"""An access token for these scopes, from the cache unless it's about to end (or `fresh`)."""
key = _cache_key(creds, scopes)
hit = _TOKENS.get(key)
if hit and not fresh and hit[0] > time.monotonic():
return hit[1]
def _form(creds, scopes):
if creds["kind"] == "service_account":
k, now = creds["key"], int(time.time())
claims = {"iss": k["client_email"], "scope": " ".join(scopes), "aud": TOKEN_URL, "iat": now, "exp": now + 3600}
Expand All @@ -169,33 +193,63 @@ def access_token(cfg, creds, scopes, fresh=False):
)
except (ValueError, TypeError, jwt.PyJWTError) as e:
raise GoogleError(f"the service account's private key can't sign: {e}") from None
form = {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion}
else:
u = creds["user"]
form = {
"grant_type": "refresh_token",
"client_id": u["client_id"],
"client_secret": u["client_secret"],
"refresh_token": u["refresh_token"],
}
with activity.call("google.token", cfg, detail={"as": creds["kind"]}):
return {"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", "assertion": assertion}
u = creds["user"]
return {
"grant_type": "refresh_token",
"client_id": u["client_id"],
"client_secret": u["client_secret"],
"refresh_token": u["refresh_token"],
}


def _swap(cfg, creds, scopes):
"""Google's token endpoint's answer for these scopes (a refusal too), as a row in the activity ledger."""
with activity.call("google.token", cfg, detail={"as": creds["kind"]}) as row:
try:
r = httpx.post(TOKEN_URL, data=form, headers={"Accept": "application/json"}, timeout=TIMEOUT)
r = httpx.post(TOKEN_URL, data=_form(creds, scopes), headers={"Accept": "application/json"}, timeout=TIMEOUT)
except httpx.HTTPError as e:
raise GoogleError(f"couldn't reach Google to sign in: {e}") from None
if r.status_code >= 400:
raise _refused(r, creds, scopes)
try:
body = r.json()
token, lasts = body["access_token"], int(body.get("expires_in") or 3600)
except (ValueError, KeyError, TypeError):
raise GoogleError("Google's sign-in answered without an access token") from None
granted = str(body.get("scope") or "").split()
missing = [s for s in scopes if granted and s not in granted]
if creds["kind"] == "authorized_user" and missing: # signed in for one way of reading, then switched to another
raise GoogleError(
f"the connected Google account doesn't allow “{CAN.get(missing[0], missing[0])}”: connect the Google account again"
)
row.end(GoogleError(f"Google refused to sign in: {_said(r)[1]}"))
return r


def _delegated(cfg, creds, scopes):
"""Which of these scopes Google lets a service account use as its subject, asked one at a time."""
return {s for s in scopes if _swap(cfg, creds, (s,)).status_code < 400}


def access_token(cfg, creds, scopes, fresh=False):
"""An access token for these scopes, from the cache unless it's about to end (or `fresh`). A service account acting
as someone whose delegation has a broader scope instead (COVERS) signs in with that."""
key = _cache_key(creds, scopes)
hit = _TOKENS.get(key)
if hit and not fresh and hit[0] > time.monotonic():
return hit[1]
r = _swap(cfg, creds, scopes)
acting = creds["kind"] == "service_account" and creds["subject"]
if r.status_code >= 400 and acting and _said(r)[0] == "unauthorized_client":
broader = tuple(dict.fromkeys(COVERS.get(s, s) for s in scopes))
if broader != tuple(scopes):
r2 = _swap(cfg, creds, broader)
if r2.status_code < 400:
r = r2
if r.status_code >= 400:
raise _not_delegated(creds, scopes, _delegated(cfg, creds, tuple(dict.fromkeys((*scopes, *broader)))), _said(r)[1])
if r.status_code >= 400:
raise _refused(r, creds, scopes)
try:
body = r.json()
token, lasts = body["access_token"], int(body.get("expires_in") or 3600)
except (ValueError, KeyError, TypeError):
raise GoogleError("Google's sign-in answered without an access token") from None
granted = str(body.get("scope") or "").split()
missing = [s for s in scopes if granted and s not in granted]
if creds["kind"] == "authorized_user" and missing: # signed in for one way of reading, then switched to another
raise GoogleError(
f"the connected Google account doesn't allow “{CAN.get(missing[0], missing[0])}”: connect the Google account again"
)
_TOKENS[key] = (time.monotonic() + max(lasts - 60, 30), token)
return token

Expand Down
2 changes: 1 addition & 1 deletion fastapi_backend/app/domain/sources.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,7 +288,7 @@ def test(db, cfg, sid):
"limited": f"{BACKENDS[src['type']]['label']} is rate limiting this account: {e}"[:300],
}
except (RuntimeError, ValueError, subprocess.TimeoutExpired) as e:
health = {"ok": False, "checked_at": store.now(), "error": str(e)[:300]}
health = {"ok": False, "checked_at": store.now(), "error": str(e)[:1000]} # room for a fix to paste (google_auth)
db.q("UPDATE $r SET health = $h", r=R("storage_source", sid), h=health)
return health

Expand Down
25 changes: 21 additions & 4 deletions fastapi_backend/tests/api/test_gmail_source.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from app.domain import convert, feeds, gmail, google_auth, sources, store
from tests import fake_imap
from tests.api.test_mail_calendar_sources import BUOYS, REPORT, _email
from tests.fake_gmail import GMAIL_READ, MAIL, Google
from tests.fake_gmail import COMPOSE, GMAIL_READ, MAIL, Google
from tests.helpers import drain, login, make_user

R = store.R
Expand Down Expand Up @@ -55,11 +55,11 @@ def _key(google, scopes=(GMAIL_READ,), domain="harbour.example"):
)


def _service_account_source(client, admin, google, user=MARA, **kw):
def _service_account_source(client, admin, google, user=MARA, write=None, **kw):
body = {
"name": "Mara's mail",
"type": "gmail",
"params": {"user": user},
"params": {"user": user, **({"write": write} if write else {})},
"secrets": {"service_account_credentials": _key(google, **kw)},
}
r = client.post("/api/v1/sources", headers=admin, json=body)
Expand Down Expand Up< 8 /tool-ti 10BC8 p> @@ -174,7 +174,8 @@ def test_what_google_says_when_a_service_account_cant_read(client, admin, google
assert (
not h["ok"]
and "1234567890" in h["error"]
and "domain-wide delegation for https://www.googleapis.com/auth/gmail.readonly" in h["error"]
and "delegation doesn't include https://www.googleapis.com/auth/gmail.readonly; it allows none of them" in h["error"]
and "paste these OAuth scopes: https://www.googleapis.com/auth/gmail.readonly." in h["error"]
)
h = _service_account_source(client, admin, google, user="ann@elsewhere.example")["health"]
assert not h["ok"] and "can't act as ann@elsewhere.example" in h["error"]
Expand All @@ -184,6 +185,22 @@ def test_what_google_says_when_a_service_account_cant_read(client, admin, google
assert r.status_code == 400 and "via is one of" in r.text


def test_a_delegation_that_lacks_a_scope_says_which(client, admin, google):
"""Google refuses a whole sign-in when one scope isn't delegated: the error names it, and what is allowed."""
h = _service_account_source(client, admin, google, scopes=(GMAIL_READ,), write="yes")["health"]
assert not h["ok"] and "doesn't include https://www.googleapis.com/auth/gmail.compose;" in h["error"], h
assert f"it allows {GMAIL_READ}." in h["error"] and f"paste these OAuth scopes: {GMAIL_READ},{COMPOSE}." in h["error"]


def test_a_delegation_of_all_of_gmail_covers_reading_and_drafts(client, admin, google):
"""A client delegated all of Gmail (as backup tools ask for) is refused gmail.readonly by Google, so Lens signs in
with the scope the admin gave."""
google.add(REPORT, ["INBOX"], at=1790000000000)
src = _service_account_source(client, admin, google, scopes=(MAIL,), write="yes")
assert src["health"]["ok"], src["health"]
assert {t["scope"] for t in google.access.values()} == {MAIL}


def _account(google, refresh, scope=GMAIL_READ):
"""A Google account's authorized user JSON (as gcloud writes it), its refresh token known to the fake Google."""
google.refresh[refresh] = {"email": MARA, "scope": scope, "client_id": google.client_id}
Expand Down
2 changes: 2 additions & 0 deletions fastapi_backend/tests/api/test_scaling.py
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ def let_go_unit(self, db, name, by="scaling"):

mesh = Mesh()
monkeypatch.setattr(scaling, "mesh_module", lambda: mesh)
# let go at the test's clock: at today's, the unit would have run since NOW and spent the group's budget
monkeypatch.setattr(scaling, "_now", lambda: NOW)
cfg = on(db, cfg)
pid = provider(client, h)["id"]
gid = group(client, h, pid, min=1, max=1, cooldown_seconds=0, warmup_seconds=0).json()["id"]
Expand Down
2 changes: 1 addition & 1 deletion threat-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,7 @@
"parameter": "service account key file or connected account's refresh token, calendar ids, impersonate; the events Google returns",
"attacker_controllable": "yes",
"component": "outbound",
"caller_must_enforce": "admin creates sources; read-only calendar.readonly scope; Google's own token endpoint and API address (a key file's token_uri is ignored); event text escaped into its .ics without control characters",
"caller_must_enforce": "admin creates sources; read-only calendar.readonly scope, or the broader scope an admin already delegated to a service account acting as someone (google_auth.COVERS), kept in memory and used only for the narrower scope's calls; Google's own token endpoint and API address (a key file's token_uri is ignored); event text escaped into its .ics without control characters",
"provenance": "documented",
"source": "docs/processing.md \"The token endpoint is always Google's own\""
},
Expand Down
Loading
Loading
0