← mohdarif.com · All notes
Product Security · Architecture Review

Reviewing a Google Drive Integration for a Multi-Tenant AI Search Product

How to review a feature you have never seen: what to ask first, which findings matter most, and how to say what is proven and what is only possible.

This post is for security engineers and developers who are asked to review a feature they have never seen before. It follows one realistic review from the first question to the findings. The feature connects a customer's Google Drive to an AI search product. You do not need to know OAuth or vector databases in advance. Each term is explained when it first appears, and each finding builds on the one before it.

The review uses a method that matters as much as the findings: for every weakness, say what is proven and what is only possible. It is easy to jump from "a check is missing" to "an attacker can steal everything". Reviewers who do that lose credibility with the engineers they work with. We will practice the careful version throughout.

1. The feature

The product is a multi-tenant SaaS application. "SaaS" means customers use it over the internet instead of installing it. "Multi-tenant" means many customer organizations (tenants) share the same running system, so one customer's data must never reach another.

The new feature lets a customer connect their Google Drive. The platform copies the documents, splits them into pieces, indexes them, and lets users search and summarize them with AI. The engineering team describes it like this:

Usersfrom many tenantsSaaS APIchecks the JWTtenant_id comes from itQueuesync jobsShared workersone identity, all tenantscan decrypt every tokenPostgreSQLshared tables, tenant_idencrypted refresh tokensVector databaseone shared indextenant_id on each chunkGoogle Driveoutside our controlcontents are untrusted
Figure 1. The architecture the review starts from. The shared workers and the untrusted Google Drive content are where most of the later findings sit.

2. How to start a review of something unfamiliar

You will not be handed a list of threats. A reliable approach is to ask three kinds of questions before judging anything:

  1. Where does each identity come from? Who is the user, which tenant are they in, and is that read from something the server verified or from something the client sent?
  2. Where does the data live, and who can read it? Follow a document from Google into the database and the vector index, and then out to the AI answer.
  3. What does each background job carry, and does it re-check anything? Anything that runs without a user in front of it needs its own authorization, because no login protects it.

The answers in this case:

3. A claim to test: "semantic search can bypass the tenant filter"

A common first reaction is: "AI search is based on meaning, so a user could just ask for another tenant's documents and get them." That sounds right and is not what the evidence shows.

results = vector_db.search(
    query=user_question,
    filter={"tenant_id": authenticated_user.tenant_id},
)

The filter is applied by the database, outside the model. It limits which chunks are even considered. A question that mentions another company does not change that filter, because the filter is not part of the question. The same is true of a prompt-injection instruction hidden in a document: text that the model reads cannot change a rule that the database enforces.

So the honest statement is: if the filter is applied everywhere and the metadata is correct, cross-tenant retrieval is not possible through the question alone. To claim a leak you need evidence of another weakness, such as:

For stronger isolation, some teams use a separate namespace or collection per tenant instead of one shared index with a filter. That moves the boundary from a parameter every query must remember to a structure the query cannot skip. It costs more to operate. The point for a reviewer is to check the filter on every path, not to assume it, and to treat the shared index as a place where a single missing parameter becomes a leak.

A rule that helps: never let the model be the thing that enforces access. The model only sees what the application hands it. Authorization has to happen before that.

4. Finding: the sync endpoint and the worker

This is the first place I would spend time, because it joins three risky facts: the user chooses an ID, workers are shared across tenants, and workers can use every stored refresh token. Here is the code the team shows:

@app.post("/api/integrations/google-drive/sync")
def trigger_sync(request):
    user = validate_jwt(request.headers["Authorization"])
    connection_id = request.json["connection_id"]
    queue.publish({"connection_id": connection_id, "requested_by": user.id})
    return {"status": "queued"}

def process_sync_job(job):
    connection = database.get_connection(job["connection_id"])
    refresh_token = decrypt(connection.refresh_token)
    google_token = refresh_google_access_token(refresh_token)
    documents = fetch_google_drive_documents(google_token)
    index_documents(documents, tenant_id=connection.tenant_id)

The developer says: "The user is authenticated, and we always index with the tenant ID stored in the database. So this is secure."

What does the attacker control? Not the user ID: that comes from the verified token. They control connection_id, which comes from the request body. This is the same object-level authorization problem described in the access control post: the API checks that the caller is logged in, but never checks that this caller may use this connection.

Now trace what happens when a user from tenant A sends tenant B's connection ID:

Tenant A uservalid JWTsends B's connection_idAPIchecks the tokennot the connectionQueuejob holds B'sconnection_idWorkerdecrypts B's tokenindexes under tenant BEstablished by this codeA can trigger a sync job on B's Google connectionusing B's stored refresh tokenNot established by this codeThat A can read B's documents. They areindexed under tenant B, not tenant A
Figure 2. The vulnerability is real, but the proven impact is an unauthorized privileged action, not a data leak.

This is the careful reading. Proven: tenant A can make the platform use tenant B's Google connection to run a sync. The effects can include extra Google API calls (and possible quota exhaustion or rate-limiting for tenant B), disrupted or unexpected syncs, and processing another customer's data without their authorization. Not proven: that A receives B's documents, because they are indexed with connection.tenant_id, which belongs to B. A confidentiality impact needs a second weakness, such as a retrieval path that ignores tenant, an exposed job result, or a bug in how tenant_id is set.

The way to say it in a review: "An attacker can trigger an unauthorized sync of another tenant's connection. Whether that becomes a data disclosure depends on where the results go and whether another access control weakness exposes them."

The fix has to be in two places

At the API: load the connection, then decide whether this user may sync it. Do not assume the rule is "the user owns it". In an enterprise product, several admins might manage one organization-wide connection, so the real rule comes from your integration policy: tenant membership, role, and any sharing rules. When the answer is no, return the same "not found" response you return for a connection that does not exist.

connection = database.get_connection(connection_id)
if connection is None or not can_sync_connection(user, connection):
    raise NotFound()
queue.publish({"connection_id": connection.id, "requested_by": user.id})

At the worker: a queue message is input, not proof. Anyone or anything that can write to the queue can forge one. The worker should load the canonical connection record, confirm the job is genuine (for example by signing jobs or by looking the request up in a trusted table), check the connection is still active and the requester is still allowed, and only then decrypt the token. For scheduled syncs with no user, authorize against the connection's own sync policy.

Notice what the worker check protects. A request that the API rejects never reaches the queue. A forged or stale message that does reach the queue is stopped before any token is decrypted or any Google call is made. That order matters, because the sensitive operation is using the credential.

Reduce what a single worker can do

Even with correct checks, the design has a large blast radius. Every worker can decrypt every tenant's refresh token with one shared key, so one compromised worker exposes all of them. Encryption at rest protects a stolen disk. It does nothing against a compromised application, because the application holds the key. Ways to reduce this:

How to verify: create two tenants with separate test connections and synthetic documents. Then check that tenant A syncing its own connection works, tenant A submitting tenant B's ID is denied, a same-tenant user without integration rights is denied, an admin with rights is allowed, a connection revoked before the job runs makes the worker refuse, and a forged queue message naming another tenant's connection is rejected. For every denied case, also check that no token was decrypted, no Google call was made and nothing was indexed. A clean error response is not enough: the privileged action itself must not happen.

5. Finding: how accounts get linked

Next, look at how a customer connects Google in the first place. The app starts the flow like this:

GET https://accounts.google.com/o/oauth2/v2/auth
    ?client_id=OUR_CLIENT_ID
    &redirect_uri=https://saas.example.com/oauth/callback
    &response_type=code
    &scope=https://www.googleapis.com/auth/drive.readonly

After the user approves, Google redirects the browser back to /oauth/callback?code=AUTHORIZATION_CODE. The backend swaps the authorization code (a one-time value) for tokens and stores the refresh token under the currently logged-in SaaS user. The manager says: "Google handles authentication and authorization, so our OAuth integration is secure."

Google handles the consent screen. It does not know which SaaS account started the flow. That link is the application's job, and here nothing protects it. There is no state parameter and no PKCE.

What the attack looks like

This is called account-linking CSRF (cross-site request forgery: making a logged-in user's browser perform an action they did not intend).

  1. The attacker starts the flow with their own Google account and approves it. Google redirects back with a valid, unused code. The attacker stops before sending that callback request to the SaaS app, so the code stays unused.
  2. The attacker gets a victim, who is logged in to the SaaS app, to open the callback URL with the attacker's code (a link, an image tag or a redirect).
  3. The SaaS backend receives the callback in the victim's session, exchanges the code and stores the result under the victim's account.
  4. The victim's SaaS account is now linked to the attacker's Google Drive.

Required conditions: the code is valid and unused, the victim's session is present on the callback, and the app does not check that the callback belongs to a flow this user started. Note what this is and is not. It does not steal the victim's Google credentials. It makes the victim's account trust the attacker's data source, which can fill the victim's search with attacker-controlled documents or support a prompt-injection attempt. The actual impact depends on what the integration does after linking.

What state and PKCE do

state is a random value your app creates, stores against the user's session, sends to Google, and checks when the callback arrives. If the value does not match the one stored for this session, the callback is rejected. It should be unpredictable, single-use and short-lived. Google's own documentation tells clients to prevent CSRF and suggests state, and RFC 9700 (the current OAuth security best practice) says clients must prevent CSRF, either with a one-time state value or by relying on PKCE after confirming the server supports it. Note that state does not validate redirect URIs and does not stop open redirects. Those need their own controls.

PKCE (pronounced "pixie") protects the code itself, so a stolen or injected code is useless to the attacker. It works like this, and the roles are easy to mix up:

  1. The client creates a random secret, the code verifier, and keeps it.
  2. It computes the code challenge from the verifier: BASE64URL(SHA256(verifier)). This is the S256 method.
  3. It sends the challenge (and the method) in the authorization request.
  4. After getting the code, it sends the verifier in the token request.
  5. The server hashes the verifier and compares it with the challenge it stored. A mismatch means the exchange is refused.

Memory aid: the verifier is the secret, and the challenge is derived from it. An attacker who sees only the challenge cannot work backwards to the verifier. (For a longer walkthrough with a live demo, see the PKCE post.) RFC 9700 requires PKCE for public clients and recommends it for all kinds of clients, including web applications. Using both state and PKCE is a sensible default.

Tests for this step: a callback with no state, with another session's state, and with a reused state must all be rejected. A wrong PKCE verifier must fail the token exchange. A replayed code must fail. An unregistered redirect URI must be refused by Google. Starting a flow as user A and finishing it in user B's session must not create a connection.

6. Finding: correct OAuth, wrong authorization

Suppose the team adds everything above: random single-use state, PKCE with S256, strict redirect URIs, one-time codes and encrypted tokens. There is one more business rule:

"An organization can connect multiple Google accounts. Any organization member can start the flow. Once connected, the new connection is available to the organization's shared sync system."

The developer says: "We have secured OAuth. No account-linking concerns remain." The OAuth flow is now sound. The problem is somewhere else.

OAuth answers one question: did this person let our app read this Google account? It does not answer: should an ordinary member be allowed to add an organization-wide data source? Those are separate decisions, and only the second one is the SaaS product's business.

Here is the attack, and it needs no protocol bug. An ordinary employee, or a malicious insider, connects a Google account of their choice and completes OAuth correctly. The platform syncs the account's documents and indexes them into the organization's shared search. Two kinds of harm follow:

The impact depends on how the platform shares indexed documents. It is not guaranteed by OAuth succeeding. But it is a product decision nobody made explicitly, which is why it is a review finding.

Separate three decisions

OperationWhat it should require
Connect a personal Google accountThe user's consent, and permission to create a personal integration
Register an organization-wide data sourceAn explicit organizational decision: an administrator or designated integration manager approves it
Make documents searchable by other peopleDocument-level visibility rules, so sharing in the SaaS follows who may see each document

A sensible design lets ordinary members connect personal sources that only they can search by default. Organization-wide sources need an approver. Every connection, approval, scope change and disconnect is written to an audit log.

7. Finding: scope and consent

A scope is the permission level an app asks for. drive.readonly lets the app read all the files the connected account can reach. Google classifies it as a restricted scope. For comparison, Google recommends drive.file for most use cases and classifies it as non-sensitive. It limits the app to files the user opens with the app or shares through the Google Picker, so the user chooses which files the app can see.

This feature indexes everything it can reach and gives users no way to choose. In a personal Drive, that includes tax papers, medical records and unrelated personal files. The user did click "Allow" on Google's consent screen, so this is not automatically a privacy violation. The question is whether what the app asks for, what it processes, how long it keeps it and what it discloses match what the user and the organization reasonably expect. Better designs:

8. Document content is untrusted input

Everything that comes out of Google Drive is content someone else wrote. Indirect prompt injection means hiding instructions in a document that the AI later reads as part of its context, hoping it obeys them. It helps to ask what the injected text can actually reach. Three outcomes are very different:

What the document doesWhat it needs to succeedTypical impact
Influences the answerThe chunk is retrieved and the model follows itMisleading or manipulated summaries
Makes the model use a privileged toolThe assistant has tools (send, share, call APIs) with real permissionsActions taken on the user's behalf
Reaches another tenant's dataA separate authorization failure in retrieval or toolsCross-tenant disclosure (not caused by the document alone)

The third row is the one people over-claim. A hidden instruction cannot by itself override a filter the database enforces, or choose which tenant a worker runs as. It only becomes dangerous when the surrounding system gives the model powers or data it should not have. The defense is to keep the model's inputs and powers small: retrieval limited to what the user may see, no tools that act on behalf of the user unless necessary, and confirmation for sensitive actions. The AI document platform threat model goes deeper on this chain.

9. A review checklist

Two further topics deserve their own deep dive, because the bugs are subtle and the impact is high: permissions that change after content is synced, cached AI answers, and long-running jobs that outlive the authorization that started them. They are covered in the companion post, When Authorization Goes Stale.

10. Things that sound safe but are not

Summary

References