Google Directory
Read-only proxy for the Google People API directory endpoints. Its job is to resolve the opaque principal IDs every other Google connector hands back.
Chat, for example, returns members and message senders as bare numeric IDs and nothing else:
{ "member": { "name": "users/102770538280811199852", "type": "HUMAN" } }
No email, no display name — the Chat API does not return member email
addresses and omits displayName for humans. DM spaces come back with no
displayName and no participant list at all. And two shipped Chat tools,
find_direct_message and add_member, require a users/{id} that no
Chat tool can produce. So "DM Bruno" is unreachable without this connector.
The same gap shows up in Calendar (attendee emails needing profiles), Drive
(list_file_permissions returns permission holders) and Docs (comment
authors). This is shared infrastructure, not a Chat patch.
Why People API and not Admin SDK
Both can resolve a principal. The deciding constraint is that Google directory reads here are passthrough-only:
| People API (used here) | Admin SDK Directory | |
|---|---|---|
| Scope | directory.readonly — ordinary user scope | admin.directory.user.readonly — admin only |
Works under idp_passthrough | For every caller | Only if the caller is a Workspace admin |
Under a passthrough connector the Admin SDK would resolve identities for
admins and fail for everyone else — the wrong shape for a primitive the rest
of the Google family depends on. There is deliberately no google_sa
mode: service-account directory reads would let an agent enumerate the
roster with no human in the loop, which is the main risk this connector is
built to bound.
Tools
| Tool | Direction | Notes |
|---|---|---|
search_directory_people | name/email → ID | The targeted lookup. Prefer this. |
batch_get_people | IDs → profiles | Resolves a whole member list in one call. |
get_person | ID → profile | Single lookup; use batch_get_people for more than one. |
list_directory_people | — | Whole-roster enumeration. Separate scope. |
batch_get_people is the one to reach for when attributing a Chat member or
sender list. An agent that loops get_person instead produces N audit rows
and N chances to drift past a page cap.
Both directory-wide tools need an explicit sources argument — the People API
refuses a call that names no source (HTTP 400 Must request at least one source). DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE is the Workspace staff
directory; add DIRECTORY_SOURCE_TYPE_DOMAIN_CONTACT for the domain's shared
external contacts. The schema marks it required, so a client supplies it
without prior knowledge.
Scope model
| Scope | Used for | |
|---|---|---|
| PBAC scopes (internal) | directory:read | Targeted resolution — search, get, batch get |
directory:roster | Whole-roster enumeration (list_directory_people) only | |
| Upstream OAuth scope | directory.readonly | The Google-side grant the user consents to |
The split is the primary enumeration control, and it is stronger than any
rego rule: don't grant directory:roster and whole-roster reads are
refused by the gateway's own scope check. Targeted resolution — the actual
use case — needs only directory:read.
Default policy
Ships policy/directory.rego, package
pbac.connectors.identos.google_directory. Four things:
1. IdP-routing subject obligation
Same shape as drive/gmail/calendar — a non-Google session in
idp_passthrough mode triggers a require_authn_at obligation.
2. allowed_person_fields allow-list
The People API field mask decides whether a directory read returns "name and
work email" or "name, home address, birthday and phone". This rule denies any
request whose readMask / personFields names a field outside the list.
An allow-list, not a deny-list — deliberately breaking the convention set
by private_calendar_ids / blocked_space_ids elsewhere. A deny-list fails
open on People API fields nobody enumerated: birthdays, genders,
addresses, phoneNumbers, biographies, relations, and whatever Google
adds next. Cost: adding a legitimately-needed field takes an operator edit.
Recommended value: ["names", "emailAddresses", "organizations", "photos"] —
exactly enough to answer "who is users/1027705…".
Empty or unset means no restriction. That is the intended observe-mode posture for a first rollout.
3. Fail-closed on an invisible field mask
Rule 2 reads the mask out of the tool arguments. Direct-HTTP callers reach the gateway with no tool-arguments object at all, so their mask is invisible and rule 2 would silently pass them.
So once an allow-list is configured, a request whose mask policy cannot see
is denied. The practical effect: with an allow-list set, this connector
is MCP-path-only — /gateway/google-directory/** via the raw HTTP proxy
returns a policy denial. That is a real functional restriction and it is the
right trade for a directory: the alternative is an allow-list that anyone can
step around by changing transport.
4. max_page_size
Caps pageSize on a single call. It does not bound cumulative paging —
policy is evaluated per request and has no memory, so a caller can page fifty
times under any cap. Bounding that needs a rate-limit obligation, which the
gateway does not have.
Partial control: blocked_person_ids
Shields individuals on the ID-addressed routes (get_person,
batch_get_people) only. It cannot remove a shielded person from
search_directory_people or list_directory_people results — that needs
response-body filtering, and the only response-side primitive available
(filter_words) does whole-word literal replacement and cannot target a JSON
field. Labelled partial here rather than implied to be complete.
Both sides are normalized to the resource name's last segment, so the caller
cannot step around the shield by switching spelling: policy sees the original
tool arguments (GatewayCallRequest passes args verbatim), while the gateway
happily strips a people/ prefix off a bare-ID path param before calling
upstream — so the two spellings address the same profile and must compare
equal.
Supplying operator data
KEY=pbac.operator.connectors.identos.google-directory
curl -X PUT "http://localhost:8080/admin/api/policy-data/by-key?dataKey=$KEY" \
-H "X-Admin-API-Key: $PBAC_ADMIN_API_KEY" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg k "$KEY" --argjson s '{
"allowed_person_fields": ["names", "emailAddresses", "organizations", "photos"],
"max_page_size": 100,
"blocked_person_ids": ["102770538280811199852"]
}' \
'{dataKey:$k, payload:($s|tojson)}')"
blocked_person_ids accepts either spelling — 102770538280811199852 or
people/102770538280811199852. Both shield the same person, because the
gateway accepts either form on get_person's person_id.
Operational notes
Directory lookups record who looked up whom. A
search_directory_people query argument is a person's name, and
tool_arguments is on the C7 rs_context audit allow-list
(RsContextProperties.DEFAULT_ALLOWED_KEYS), which is flat — so the query is
persisted to the introspect audit row rather than dropped.
This is accepted, not overlooked. Removing tool_arguments from the
allow-list would not stop personal data reaching audit_log, because
identifiers are routinely path segments and GatewayAuditContext records the
path:
/gateway/google-calendar/calendar/v3/calendars/alec%40identos.ca/events
So the correct posture is that audit_log is a PII store and should be
governed as one — retention and access controls, not per-field redaction.
Directory rows make it sharper, since the aggregate is a social graph of who
looked up whom, but they do not create the category.
Google's numeric ID spaces are not interchangeable. People API's
people/{id} is Chat's users/{id} — confirmed live, which is what makes
this connector work. But Drive permission IDs are a different space: Drive
returns 20-digit IDs with leading zeros (01611141363991203537) where Chat
returns 21 digits without (102770538280811199852). Verify before assuming an
ID from one Google API is usable in another.
Full resource names are accepted where a bare ID is expected. Passing
people/102770538280811199852 to get_person works, as does the bare
102770538280811199852 — the gateway strips a redundant collection prefix, so
batch_get_people output can be handed straight to get_person. Same for
Chat's spaces/... values.
See Google Workspace: multi-connector setup for how this composes with drive, gmail, calendar and chat on one Google IdP.
Manifest reference
- ID:
identos.google-directory - Version:
1.0.0 - Resource type:
urn:connector:identos:google-directory
Supported auth modes
| Type | Details |
|---|---|
idp_passthrough | requires IdP google |
Setup fields
| ID | Label | Default | Secret? | Notes |
|---|---|---|---|---|
base_url | API base URL | https://people.googleapis.com | no | — |
Scopes
| Scope |
|---|
directory:read |
directory:roster |
Routes
| Method | Pattern | Scope | Resource template |
|---|---|---|---|
GET | /v1/people:searchDirectoryPeople | directory:read | — |
GET | /v1/people:batchGet | directory:read | — |
GET | /v1/people/{person_id} | directory:read | person://{{person_id}} |
GET | /v1/people:listDirectoryPeople | directory:roster | — |
MCP tools
| Name | Scope | Description |
|---|---|---|
search_directory_people | directory:read | Search the Workspace directory by free text (name, email, title) and return matching profiles. This is the email-or-name to ID direction — use it to turn 'Bruno' into a people/{id} you can pass to Chat's find_direct_message or add_member. Targeted lookup: prefer this over list_directory_people. |
batch_get_people | directory:read | Resolve many principal IDs to profiles in ONE call. This is the ID to human direction and the preferred way to attribute a member or sender list — pass every resourceName at once rather than looping get_person, which multiplies audit rows and page-cap risk. resourceNames take the form people/{id}; the {id} is the same numeric principal ID Chat returns as users/{id}. |
get_person | directory:read | Get a single directory profile by its numeric person ID. Use batch_get_people instead when resolving more than one. |
list_directory_people | directory:roster | Enumerate the entire Workspace directory, paginated. Whole-roster read — requires the separate directory:roster scope, which most clients should not be granted. Prefer search_directory_people for any task that only needs specific people. |
Operator data schema
Keys the operator can supply under data.pbac.operator.connectors["identos.google-directory"].* — consumed by the connector's policy.
| Key | Type | Description |
|---|---|---|
allowed_person_fields | array | Allow-list of People API person fields this connector may request. When non-empty, any request whose readMask / personFields names a field outside the list is denied, AND any request whose field mask is not visible to policy is denied (fail-closed). Leave empty for observe mode — no field restriction. Recommended: ["names","emailAddresses","organizations","photos"]. |
max_page_size | number | Per-request cap on pageSize. Bounds a single enumeration call; it does NOT bound cumulative paging (policy is stateless per request — see the connector README). Omit or set 0 for no cap. |
blocked_person_ids | array | Person IDs shielded from get_person and batch_get_people — executives, at-risk staff. Either spelling works: the bare numeric ID (102770538280811199852) or the People API resource name (people/102770538280811199852). NOTE: this is a partial control. It cannot suppress a blocked individual from search_directory_people or list_directory_people results, because that needs response filtering the gateway cannot do. |