Google Drive#
Copies who may read each Drive file onto the file’s chunks, so that
MetadataACLAuthorizer filters searches the way Drive’s own sharing does. Call
it at ingestion, and again whenever a file’s sharing changes. No extra dependency: you pass the
Drive service from Google’s client, which you install yourself.
pip install wardowl google-api-python-client google-auth
from google.oauth2 import service_account
from googleapiclient.discovery import build
from wardowl.sync import with_access
from wardowl.sync.google_drive import GoogleDriveACL
credentials = service_account.Credentials.from_service_account_file(
"sync-account.json",
scopes=["https://www.googleapis.com/auth/drive.readonly"],
subject="indexer@example.com", # domain-wide delegation: the account your ingestion reads files as
)
drive = GoogleDriveACL(build("drive", "v3", credentials=credentials), domains=["example.com"])
access = drive.access(file_id) # who may read the file
backend.set_metadata({cid: with_access(metadata, access) for cid, metadata in chunks.items()})
chunks maps the file’s chunk ids to the metadata you stored with them. with_access replaces
allowed_users and allowed_groups and keeps every other key, and set_metadata stores the
result without embedding the chunks again
(Changing a document’s permissions).
How Drive sharing maps onto subjects#
Drive grant |
Gives |
A |
|---|---|---|
A user, any role |
|
its id is that address, lowercased |
A group, any role |
|
its |
A domain, findable in search |
|
its |
Anyone, findable in search |
|
always, or in those domains |
Every Drive role (reader, commenter, writer, file organizer, organizer and owner) can read the
file’s content, so each one counts. Use the same addresses as subject ids, and give each subject
the domain: group of its own domain.
Drive groups can contain groups, and a grant to a group reaches the members of the groups inside
it. A subject’s groups must therefore list every group it belongs to, directly or not. Cloud
Identity’s
searchTransitiveGroups
returns them in one call (Workspace Enterprise and Cloud Identity Premium); otherwise call the
Admin SDK’s groups.list(userKey=...) for the user and again for each group found. Resolve them
when the user signs in, as for any group-based permission: the chunks need no update when someone
joins or leaves a group.
What grants nothing#
A grant is skipped when it does not let its holder open the file’s content, or when it cannot be read with certainty. Skipping fails closed: the file is readable by fewer people, never more.
Sharing by link alone. “Anyone with the link” and “anyone in example.com with the link” let in whoever holds the link. A search across every document would show the content to people who never had the link, so these grants count only with
GoogleDriveACL(service, link_sharing=True). Grants that are also findable in search (allowFileDiscovery) always count.Deleted accounts. A deleted account’s address can later be given to someone else.
Grants with an expiry time, unless you pass
expiring_grants=True; see grants that expire.Expired grants, and grants whose expiry time cannot be read, in every case.
Grants that belong to a view. On a limited-access folder, a
metadataview grant shows the folder’s details, not its content;publishedgrants are left out as well.Inherited grants on a limited-access item. Only owners, organizers and people added to the item directly can open it.
Grants to non-members in a shared drive open only to its members.
Grants outside your domains, when you pass
domains; see below.Unknown types and roles, and grants missing the address or domain they need.
Grants that expire#
Drive lets a share end at a set time (expirationTime), and removes the grant itself when it
does, which your sync may never be told about. Copied onto the chunks, such a grant would keep
granting access after Drive stopped. So by default a grant with an expiry time counts for nothing,
even before it expires.
GoogleDriveACL(service, expiring_grants=True) counts it until it expires. The answer’s
expires_at is then the earliest expiry among the grants that count, and with_access writes it
to the chunks as access_expires_at, in UTC to the second ("2026-10-17T12:00:00Z").
MetadataACLAuthorizer does not read that field: sync the file again by that time, for example
from a job that looks for chunks whose access_expires_at has passed, or enforce it in a policy.
A later sync without expiring grants removes the field.
drive = GoogleDriveACL(service, domains=["example.com"], expiring_grants=True)
access = drive.access(file_id)
if access.expires_at is not None:
schedule_sync(file_id, at=access.expires_at) # your scheduler
Credentials#
The reader needs the drive.readonly scope: drives.get, which reads a shared drive’s settings,
accepts no narrower one. Use the identity your ingestion already reads the files with, for
example a service account with
domain-wide delegation acting as an indexing user
who can see each file’s sharing and is a member of each shared drive you index. An administrator
does not see other users’ files or shared drives they are not a member of, so acting as one is
not enough.
A file that is gone for this identity, because it was deleted or is no longer shared with it
(HTTP 404, or 403 with the reason notFound or insufficientFilePermissions), is readable by
nobody: access returns Access(), and writing that to the chunks revokes every grant. An
identity that cannot see a file’s sharing therefore hides the file from every search, so check
that it can. Any other error from the API, such as a rate limit (429) or a server error (5xx), is
raised: a file whose sharing could not be read keeps the permissions it had until a later sync
succeeds.
Keeping permissions current#
Drive’s changes feed reports every file whose content or sharing changed since a saved page token. Read it on a schedule and sync each changed file you index:
changes = build("drive", "v3", credentials=credentials).changes()
token = load_token() or changes.getStartPageToken(supportsAllDrives=True).execute()["startPageToken"]
fields = "nextPageToken,newStartPageToken,changes(fileId,removed)"
while token:
request = changes.list(pageToken=token, includeItemsFromAllDrives=True, supportsAllDrives=True, fields=fields)
page = request.execute()
for change in page.get("changes", []):
if change.get("removed"):
backend.delete(chunk_ids(change["fileId"]))
elif is_indexed(change["fileId"]):
access = drive.access(change["fileId"])
backend.set_metadata({cid: with_access(md, access) for cid, md in chunks(change["fileId"]).items()})
token = page.get("nextPageToken")
if "newStartPageToken" in page:
save_token(page["newStartPageToken"])
A removed change (removed) means the file was deleted or the identity lost access to it: delete
its chunks. If a file is deleted between a change and its sync, drive.access returns Access(),
so its chunks are readable by nobody until the feed reports the removal.
Sharing a folder changes who may read every file inside it, and the feed lists those files. Save the token only after the files are synced, so a crash repeats the work rather than skipping it. Until a change is synced, the chunks keep their old permissions; sync often enough that this window is acceptable, and see Operations for what changes take effect at once.
Checking the mapping#
The tests run the reader against the response shapes Google documents for permissions.list,
files.get and drives.get, not against a live Workspace domain. Before relying on it, compare
its answer with Drive’s sharing dialog for a few files of each kind you index: My Drive, shared
drives, limited-access folders and files shared by link.
drive_access is the mapping alone, from the permission resources Drive returns to an Access.
Use it to test your own data, or with permissions you already fetched:
from wardowl.sync.google_drive import drive_access
permissions = [{"type": "group", "role": "reader", "emailAddress": "eng@example.com"}]
assert drive_access(permissions).groups == {"eng@example.com"}