Files
fleet/ee/server/googleworkspace/google_workspace.go
T
Lucas Manuel Rodriguez 9f3f198e08 Google Workspace IdP: QA/load-test tooling (fake Admin SDK server) (#48208)
## Summary

**QA / load-test tooling** for the Google Workspace IdP integration —
lets QA exercise the real sync path at scale without a Google Workspace
tenant.

- **`tools/gw-directory-fake`** — a standalone fake of the Google Admin
SDK Directory API (not production code):
- `generate` — writes an editable JSON fixture (`-users`, `-groups`,
`-members-per-group`, `-domain`).
- `serve` — serves users/groups/members with real pagination from the
fixture, **hot-reloading** the file on change (poll modtime), plus
`-latency` / `-error-rate` (429/503) knobs. Responses are built from the
real `directory` SDK structs so the schema can't drift.
- **Fleet-side seam** (`ee/server/googleworkspace/google_workspace.go`):
- honors `token_uri` from the service-account JSON (falls back to
Google's endpoint),
- `FLEET_TEST_GOOGLE_WORKSPACE_ENDPOINT` redirects the Directory API
base to the fake (logs a warning each sync; never set in production),
- `seam_test.go` proves the loop end to end (JWT token exchange +
paginated Directory calls against a local fake over plain HTTP).

### QA workflow
```
go run ./tools/gw-directory-fake generate -users 50000 -groups 2000 -domain qa.example.com -out fixture.json
go run ./tools/gw-directory-fake serve -fixture fixture.json -addr :8091
```
Set `FLEET_TEST_GOOGLE_WORKSPACE_ENDPOINT=http://<host>:8091` on Fleet
and configure the integration with a throwaway service-account JSON
whose `token_uri` is `http://<host>:8091/token`. Edit `fixture.json`
anytime to change directory state mid-test.

> 🥞 **Stacked PR.** Base: `42915-gw-idp-vitals-5-frontend` (top of the
feature code stack, #48168).

**Related issue:** Resolves #42915

# Checklist for submitter

- [ ] Changes file added for user-visible changes in `changes/`,
`orbit/changes/` or `ee/fleetd-chrome/changes`.

## Testing

- [ ] Added/updated automated tests
- [ ] QA'd all new/changed functionality manually


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Google Workspace directory access now supports custom OAuth token
endpoints and a configurable API base endpoint for testing and special
environments.
* Added a local fake Google Workspace Directory server tool for
generating sample data and serving directory responses, including
pagination and optional fault injection.

* **Bug Fixes**
* Improved handling for Google Workspace API calls when alternate
endpoints are required.

* **Tests**
* Added coverage for overriding the Google Workspace directory endpoint.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-06-29 14:14:43 -03:00

372 lines
11 KiB
Go

// Package googleworkspace implements pulling users and groups from a Google
// Workspace directory via the Admin SDK Directory API, using a service account
// with domain-wide delegation. It maps Google's data model onto Fleet's ScimUser
// so the sync engine can populate IdP host vitals (the scim_* tables).
package googleworkspace
import (
"context"
"encoding/json"
"log/slog"
"os"
"strings"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"golang.org/x/oauth2/google"
"golang.org/x/oauth2/jwt"
directory "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
)
// Page sizes for the Directory API. Users.List allows up to 500; Groups.List and
// Members.List allow up to 200.
const (
usersPageSize = 500
groupsPageSize = 200
membersPageSize = 200
)
// tokenURIKey is the key holding the OAuth2 token endpoint in a service-account
// JSON. Real Google service-account JSON always includes it; we honor it so a
// QA/load-test fake can route the token exchange to a stub endpoint.
const tokenURIKey = "token_uri"
// endpointOverrideEnv, when set, redirects the Admin SDK Directory API base URL
// (e.g. to the gw-directory-fake tool). For QA/load testing only — never set it
// in production.
const endpointOverrideEnv = "FLEET_TEST_GOOGLE_WORKSPACE_ENDPOINT"
// directoryScopes are the read-only Admin SDK Directory API scopes that must be
// authorized for the service account's client ID via domain-wide delegation in
// the Google Admin console.
var directoryScopes = []string{
directory.AdminDirectoryUserReadonlyScope,
directory.AdminDirectoryGroupReadonlyScope,
directory.AdminDirectoryGroupMemberReadonlyScope,
}
// lowLevelAPI is the minimal Admin SDK Directory API surface the Directory needs.
// It exists so tests can supply a fake implementation without hitting Google.
type lowLevelAPI interface {
ListUsers(ctx context.Context, domain string) ([]*directory.User, error)
ListGroups(ctx context.Context, domain string) ([]*directory.Group, error)
ListGroupMembers(ctx context.Context, groupKey string) ([]*directory.Member, error)
}
// Directory implements fleet.GoogleWorkspaceDirectory.
type Directory struct {
api lowLevelAPI
domain string
logger *slog.Logger
}
// NewDirectory builds a Directory that talks to the real Admin SDK Directory API
// using the integration's service account and impersonated admin user.
func NewDirectory(ctx context.Context, intg *fleet.GoogleWorkspaceIntegration, logger *slog.Logger) (fleet.GoogleWorkspaceDirectory, error) {
api, err := newGoogleAPI(ctx, intg, logger)
if err != nil {
return nil, err
}
return &Directory{api: api, domain: intg.Domain, logger: logger}, nil
}
func (d *Directory) log() *slog.Logger {
if d.logger == nil {
return slog.New(slog.DiscardHandler)
}
return d.logger
}
// ListUsers returns every user in the configured domain mapped to a ScimUser.
func (d *Directory) ListUsers(ctx context.Context) ([]*fleet.ScimUser, error) {
users, err := d.api.ListUsers(ctx, d.domain)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list google workspace users")
}
logger := d.log()
out := make([]*fleet.ScimUser, 0, len(users))
for _, u := range users {
// A user with no ID or primary email cannot be linked to a host, so skip it.
if u.Id == "" || u.PrimaryEmail == "" {
logger.DebugContext(ctx, "skipping google workspace user with missing id or primary email",
"id", u.Id, "primary_email", u.PrimaryEmail)
continue
}
su := mapUser(u)
logger.DebugContext(ctx, "ingested google workspace user",
"external_id", u.Id,
"user_name", su.UserName,
"active", derefBool(su.Active),
"department", derefString(su.Department),
"num_emails", len(su.Emails),
// Raw organizations as returned by the Directory API, to diagnose
// missing department values (empty/absent means the API returned none).
"raw_organizations", rawJSON(u.Organizations),
)
out = append(out, su)
}
return out, nil
}
// ListGroups returns every group in the configured domain with its members'
// external IDs (Google user IDs).
func (d *Directory) ListGroups(ctx context.Context) ([]*fleet.GoogleWorkspaceGroup, error) {
groups, err := d.api.ListGroups(ctx, d.domain)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "list google workspace groups")
}
out := make([]*fleet.GoogleWorkspaceGroup, 0, len(groups))
for _, g := range groups {
if g.Id == "" {
continue
}
members, err := d.api.ListGroupMembers(ctx, g.Id)
if err != nil {
return nil, ctxerr.Wrapf(ctx, err, "list members of google workspace group %s", g.Id)
}
memberIDs := make([]string, 0, len(members))
for _, m := range members {
if m.Id == "" {
continue
}
// Only direct user members are mapped; nested groups are not expanded in v1.
if m.Type != "" && m.Type != "USER" {
continue
}
memberIDs = append(memberIDs, m.Id)
}
d.log().DebugContext(ctx, "ingested google workspace group",
"external_id", g.Id,
"display_name", groupDisplayName(g),
"num_members", len(memberIDs),
)
out = append(out, &fleet.GoogleWorkspaceGroup{
ExternalID: g.Id,
DisplayName: groupDisplayName(g),
MemberExternalIDs: memberIDs,
})
}
return out, nil
}
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
func derefBool(b *bool) bool {
return b != nil && *b
}
// rawJSON marshals a value to a compact JSON string for debug logging. It returns
// an empty string for nil and "<unmarshalable>" if marshaling fails.
func rawJSON(v any) string {
if v == nil {
return ""
}
b, err := json.Marshal(v)
if err != nil {
return "<unmarshalable>"
}
return string(b)
}
// mapUser maps a Google Directory user onto a Fleet ScimUser. ExternalID is the
// Google user ID; group membership is resolved separately from ListGroups.
func mapUser(u *directory.User) *fleet.ScimUser {
active := !u.Suspended && !u.Archived
su := &fleet.ScimUser{
ExternalID: new(u.Id),
UserName: u.PrimaryEmail,
Active: new(active),
}
if u.Name != nil {
if gn := strings.TrimSpace(u.Name.GivenName); gn != "" {
su.GivenName = new(gn)
}
if fn := strings.TrimSpace(u.Name.FamilyName); fn != "" {
su.FamilyName = new(fn)
}
}
if dept := primaryDepartment(parseOrganizations(u.Organizations)); dept != "" {
su.Department = new(dept)
}
su.Emails = mapEmails(u.PrimaryEmail, parseEmails(u.Emails))
return su
}
func groupDisplayName(g *directory.Group) string {
if name := strings.TrimSpace(g.Name); name != "" {
return name
}
return strings.TrimSpace(g.Email)
}
// mapEmails maps Google's emails onto ScimUserEmail, de-duplicating by address
// (case-insensitive) and guaranteeing the primary email is present and flagged
// primary — the host↔user linking matches on the primary email.
func mapEmails(primaryEmail string, raw []directoryEmail) []fleet.ScimUserEmail {
seen := make(map[string]int, len(raw)+1)
out := make([]fleet.ScimUserEmail, 0, len(raw)+1)
for _, e := range raw {
addr := strings.TrimSpace(e.Address)
if addr == "" {
continue
}
if _, dup := seen[strings.ToLower(addr)]; dup {
continue
}
em := fleet.ScimUserEmail{Email: addr, Primary: new(e.Primary)}
if e.Type != "" {
em.Type = new(e.Type)
}
seen[strings.ToLower(addr)] = len(out)
out = append(out, em)
}
primaryEmail = strings.TrimSpace(primaryEmail)
if primaryEmail == "" {
return out
}
if idx, ok := seen[strings.ToLower(primaryEmail)]; ok {
out[idx].Primary = new(true)
return out
}
// Primary email wasn't in the emails array; prepend it.
return append([]fleet.ScimUserEmail{{Email: primaryEmail, Primary: new(true)}}, out...)
}
// primaryDepartment returns the department of the primary organization, falling
// back to the first organization with a non-empty department.
func primaryDepartment(orgs []directoryOrganization) string {
var fallback string
for _, o := range orgs {
dept := strings.TrimSpace(o.Department)
if dept == "" {
continue
}
if o.Primary {
return dept
}
if fallback == "" {
fallback = dept
}
}
return fallback
}
// Google's directory.User exposes Emails and Organizations as untyped JSON
// (any), so we parse the slices we need via a JSON round-trip.
type directoryEmail struct {
Address string `json:"address"`
Type string `json:"type"`
Primary bool `json:"primary"`
}
type directoryOrganization struct {
Department string `json:"department"`
Primary bool `json:"primary"`
}
func parseEmails(raw any) []directoryEmail {
var out []directoryEmail
jsonRoundTrip(raw, &out)
return out
}
func parseOrganizations(raw any) []directoryOrganization {
var out []directoryOrganization
jsonRoundTrip(raw, &out)
return out
}
func jsonRoundTrip(raw any, dst any) {
if raw == nil {
return
}
b, err := json.Marshal(raw)
if err != nil {
return
}
// Best effort: malformed shapes simply yield no values.
_ = json.Unmarshal(b, dst)
}
// googleAPI is the production lowLevelAPI backed by the Admin SDK Directory API.
type googleAPI struct {
service *directory.Service
}
func newGoogleAPI(ctx context.Context, intg *fleet.GoogleWorkspaceIntegration, logger *slog.Logger) (*googleAPI, error) {
// Honor token_uri from the service-account JSON (real GSA JSON always carries
// it). Falls back to Google's endpoint when absent.
tokenURL := google.JWTTokenURL
if v := intg.ApiKey.Values[tokenURIKey]; v != "" {
tokenURL = v
}
conf := &jwt.Config{
Email: intg.ApiKey.Values[fleet.GoogleCalendarEmail],
Scopes: directoryScopes,
PrivateKey: []byte(intg.ApiKey.Values[fleet.GoogleCalendarPrivateKey]),
TokenURL: tokenURL,
Subject: intg.ImpersonatedUserEmail,
}
opts := []option.ClientOption{option.WithHTTPClient(conf.Client(ctx))}
if endpoint := os.Getenv(endpointOverrideEnv); endpoint != "" {
// QA/load-test only: redirect the Directory API to a fake server.
if logger != nil {
logger.WarnContext(ctx, "using Google Workspace Directory API endpoint override; do not use in production",
"env", endpointOverrideEnv, "endpoint", endpoint)
}
opts = append(opts, option.WithEndpoint(endpoint))
}
service, err := directory.NewService(ctx, opts...)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "create google workspace directory service")
}
return &googleAPI{service: service}, nil
}
func (a *googleAPI) ListUsers(ctx context.Context, domain string) ([]*directory.User, error) {
var users []*directory.User
err := a.service.Users.List().Domain(domain).MaxResults(usersPageSize).Pages(ctx, func(page *directory.Users) error {
users = append(users, page.Users...)
return nil
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "google workspace users.list")
}
return users, nil
}
func (a *googleAPI) ListGroups(ctx context.Context, domain string) ([]*directory.Group, error) {
var groups []*directory.Group
err := a.service.Groups.List().Domain(domain).MaxResults(groupsPageSize).Pages(ctx, func(page *directory.Groups) error {
groups = append(groups, page.Groups...)
return nil
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "google workspace groups.list")
}
return groups, nil
}
func (a *googleAPI) ListGroupMembers(ctx context.Context, groupKey string) ([]*directory.Member, error) {
var members []*directory.Member
err := a.service.Members.List(groupKey).MaxResults(membersPageSize).Pages(ctx, func(page *directory.Members) error {
members = append(members, page.Members...)
return nil
})
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "google workspace members.list")
}
return members, nil
}