Files
fleet/server/cron/google_workspace_cron.go
Konstantin Sykulev 3b329e49e7 Android certificates resent_certificate (#49171)
**Related issue:** Resolves #49007

# Checklist for submitter

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

For unreleased bug fixes in a release candidate, one of:

- [x] Confirmed that the fix is not expected to adversely impact load
test results


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

* **New Features**
* SCIM user create/reactivation, replace, patch, and delete flows now
automatically record certificate resend activities when applicable.
* Certificate resend activities are generated alongside SCIM
persistence, tied to the resulting “resent certificates”.
* **Bug Fixes**
* Improved reliability and synchronization of certificate resend
activity recording during SCIM and Google Workspace reconciliation.
* Failures to record individual resend activities no longer block the
underlying SCIM operation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-13 13:22:50 -05:00

444 lines
14 KiB
Go

package cron
import (
"context"
"fmt"
"log/slog"
"time"
"unicode/utf8"
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/fleetdm/fleet/v4/server/service/schedule"
)
const (
// googleWorkspaceSyncInterval is how often Fleet pulls the Google Workspace directory.
googleWorkspaceSyncInterval = 5 * time.Minute
// scimSyncPageSize is the page size used when loading the current scim_* state
// from the database during reconciliation.
scimSyncPageSize = 1000
)
// GoogleWorkspaceDirectoryFactory builds a directory client for the given
// integration. It is injected so cron can run without importing the EE client
// package directly and so tests can supply a fake directory. The logger is
// passed through so the directory client can emit per-user/group debug logs.
type GoogleWorkspaceDirectoryFactory func(ctx context.Context, intg *fleet.GoogleWorkspaceIntegration, logger *slog.Logger) (fleet.GoogleWorkspaceDirectory, error)
// NewGoogleWorkspaceSchedule registers the periodic Google Workspace directory
// sync. The job no-ops when no Google Workspace integration is configured.
func NewGoogleWorkspaceSchedule(
ctx context.Context,
instanceID string,
ds fleet.Datastore,
factory GoogleWorkspaceDirectoryFactory,
logger *slog.Logger,
) (*schedule.Schedule, error) {
name := string(fleet.CronGoogleWorkspaceSync)
logger = logger.With("cron", name)
s := schedule.New(
ctx, name, instanceID, googleWorkspaceSyncInterval, ds, ds,
schedule.WithLogger(logger),
schedule.WithJob(
"google_workspace_sync",
func(ctx context.Context) error {
return cronGoogleWorkspaceSync(ctx, ds, factory, logger)
},
),
)
return s, nil
}
// cronGoogleWorkspaceSync runs one sync pass and records the result so the IdP
// settings UI can surface the last sync status. It reuses scim_last_request:
// because Google Workspace and SCIM are mutually exclusive (SCIM is ignored while
// a Google Workspace integration is configured), that row represents the last IdP
// ingest regardless of source.
func cronGoogleWorkspaceSync(ctx context.Context, ds fleet.Datastore, factory GoogleWorkspaceDirectoryFactory, logger *slog.Logger) error {
appConfig, err := ds.AppConfig(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "load app config")
}
if len(appConfig.Integrations.GoogleWorkspace) == 0 {
// Not configured; nothing to do.
return nil
}
intg := appConfig.Integrations.GoogleWorkspace[0]
syncErr := syncGoogleWorkspaceDirectory(ctx, ds, factory, intg, logger)
lastRequest := &fleet.ScimLastRequest{Status: "success"}
if syncErr != nil {
lastRequest.Status = "error"
// The scim_last_request.details column is VARCHAR(255). Sync errors can wrap
// arbitrarily long messages from the Google API, so truncate before writing or
// UpdateScimLastRequest rejects the row and the failure goes unrecorded.
lastRequest.Details = truncateRunes(syncErr.Error(), fleet.SCIMMaxFieldLength)
logger.ErrorContext(ctx, "google workspace sync failed", "err", syncErr)
}
if err := ds.UpdateScimLastRequest(ctx, lastRequest); err != nil {
// Don't mask the sync error with a status-write error, but do surface it.
logger.ErrorContext(ctx, "update google workspace last sync status", "err", err)
}
return syncErr
}
// truncateRunes returns s shortened to at most maxRunes characters, preserving the
// start of the string. utf8mb4 VARCHAR(N) in MySQL counts characters (runes), not
// bytes, so we slice on runes to align with the column constraint.
func truncateRunes(s string, maxRunes int) string {
if len(s) <= maxRunes {
// Fast path: ASCII fits in maxRunes bytes -> maxRunes characters max.
return s
}
if utf8.RuneCountInString(s) <= maxRunes {
return s
}
return string([]rune(s)[:maxRunes])
}
// syncGoogleWorkspaceDirectory pulls the full directory and reconciles it into the
// scim_* tables. Everything downstream of those tables (host linking, IdP host
// vitals, host-vitals labels, Fleet variables) is source-agnostic and works
// unchanged.
func syncGoogleWorkspaceDirectory(
ctx context.Context,
ds fleet.Datastore,
factory GoogleWorkspaceDirectoryFactory,
intg *fleet.GoogleWorkspaceIntegration,
logger *slog.Logger,
) error {
dir, err := factory(ctx, intg, logger)
if err != nil {
return ctxerr.Wrap(ctx, err, "create google workspace directory client")
}
gwUsers, err := dir.ListUsers(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "list users from google workspace")
}
gwGroups, err := dir.ListGroups(ctx)
if err != nil {
return ctxerr.Wrap(ctx, err, "list groups from google workspace")
}
extIDToScimUserID, usersFailed, err := syncGoogleWorkspaceUsers(ctx, ds, gwUsers, logger)
if err != nil {
return err
}
groupsFailed, err := syncGoogleWorkspaceGroups(ctx, ds, gwGroups, extIDToScimUserID, logger)
if err != nil {
return err
}
logger.InfoContext(ctx, "google workspace sync complete",
"users", len(gwUsers), "users_failed", usersFailed,
"groups", len(gwGroups), "groups_failed", groupsFailed)
// Per-record failures don't abort the sync (one bad user/group shouldn't block
// the rest of the directory), but we surface them as an error so the sync status
// reflects the partial failure. Specifics are in the logs above.
if usersFailed > 0 || groupsFailed > 0 {
return ctxerr.Errorf(ctx, "partial sync: %d of %d users and %d of %d groups failed to ingest; see server logs for details",
usersFailed, len(gwUsers), groupsFailed, len(gwGroups))
}
return nil
}
// syncGoogleWorkspaceUsers reconciles users and returns a map of Google user ID
// (external_id) -> scim_users.id, used to resolve group membership.
func syncGoogleWorkspaceUsers(ctx context.Context, ds fleet.Datastore, gwUsers []*fleet.ScimUser, logger *slog.Logger) (map[string]uint, int, error) {
existing, err := listAllScimUsers(ctx, ds)
if err != nil {
// Failing to load the current state is fatal: without it every user would
// look new and we'd attempt to recreate the entire directory.
return nil, 0, ctxerr.Wrap(ctx, err, "list existing scim users")
}
existingByExtID := make(map[string]*fleet.ScimUser, len(existing))
for i := range existing {
u := &existing[i]
if u.ExternalID != nil {
existingByExtID[*u.ExternalID] = u
}
}
seen := make(map[string]struct{}, len(gwUsers))
extIDToScimUserID := make(map[string]uint, len(gwUsers))
failed := 0
for _, gu := range gwUsers {
if gu.ExternalID == nil || *gu.ExternalID == "" {
continue
}
extID := *gu.ExternalID
seen[extID] = struct{}{}
if ex, ok := existingByExtID[extID]; ok {
gu.ID = ex.ID
extIDToScimUserID[extID] = ex.ID
if scimUserNeedsUpdate(ex, gu) {
if _, err := ds.ReplaceScimUser(ctx, gu); err != nil {
// Best-effort: log and skip this user so one bad record doesn't
// abort the whole sync.
failed++
logger.ErrorContext(ctx, "google workspace sync: skipping user that failed to update",
"user_name", gu.UserName, "external_id", extID, "err", err)
}
}
continue
}
id, err := ds.CreateScimUser(ctx, gu)
if err != nil {
failed++
logger.ErrorContext(ctx, "google workspace sync: skipping user that failed to create",
"user_name", gu.UserName, "external_id", extID, "err", err)
continue
}
extIDToScimUserID[extID] = id
}
// Delete users that are no longer in Google Workspace. Google Workspace is the
// source of truth while configured, so any scim user not present in the pull is
// removed (cascading to host_scim_user). Guard against a misconfiguration that
// returns zero users, which would otherwise wipe all IdP data.
if len(gwUsers) == 0 {
logger.WarnContext(ctx, "google workspace returned no users; skipping user deletion to avoid data loss")
return extIDToScimUserID, failed, nil
}
for extID, ex := range existingByExtID {
if _, ok := seen[extID]; ok {
continue
}
if _, err := ds.DeleteScimUser(ctx, ex.ID); err != nil {
failed++
logger.ErrorContext(ctx, "google workspace sync: failed to delete user no longer in directory",
"scim_user_id", ex.ID, "external_id", extID, "err", err)
}
}
return extIDToScimUserID, failed, nil
}
// syncGoogleWorkspaceGroups reconciles groups and their memberships, resolving
// member Google user IDs to scim_users.id via extIDToScimUserID.
func syncGoogleWorkspaceGroups(
ctx context.Context,
ds fleet.Datastore,
gwGroups []*fleet.GoogleWorkspaceGroup,
extIDToScimUserID map[string]uint,
logger *slog.Logger,
) (int, error) {
existing, err := listAllScimGroups(ctx, ds)
if err != nil {
// Failing to load the current state is fatal (see syncGoogleWorkspaceUsers).
return 0, ctxerr.Wrap(ctx, err, "list existing scim groups")
}
existingByExtID := make(map[string]*fleet.ScimGroup, len(existing))
for i := range existing {
g := &existing[i]
if g.ExternalID != nil {
existingByExtID[*g.ExternalID] = g
}
}
seen := make(map[string]struct{}, len(gwGroups))
// scim_groups.display_name is UNIQUE; disambiguate collisions within the pull
// so one duplicate name can't fail the whole sync.
usedDisplayNames := make(map[string]struct{}, len(gwGroups))
failed := 0
for _, gg := range gwGroups {
if gg.ExternalID == "" {
continue
}
seen[gg.ExternalID] = struct{}{}
memberIDs := make([]uint, 0, len(gg.MemberExternalIDs))
for _, memberExtID := range gg.MemberExternalIDs {
if scimID, ok := extIDToScimUserID[memberExtID]; ok {
memberIDs = append(memberIDs, scimID)
}
}
displayName := uniqueDisplayName(gg.DisplayName, gg.ExternalID, usedDisplayNames)
desired := &fleet.ScimGroup{
ExternalID: new(gg.ExternalID),
DisplayName: displayName,
ScimUsers: memberIDs,
}
if ex, ok := existingByExtID[gg.ExternalID]; ok {
desired.ID = ex.ID
if scimGroupNeedsUpdate(ex, desired) {
if err := ds.ReplaceScimGroup(ctx, desired); err != nil {
// Best-effort: log and skip so one bad group doesn't abort the sync.
failed++
logger.ErrorContext(ctx, "google workspace sync: skipping group that failed to update",
"display_name", displayName, "external_id", gg.ExternalID, "err", err)
}
}
continue
}
if _, err := ds.CreateScimGroup(ctx, desired); err != nil {
failed++
logger.ErrorContext(ctx, "google workspace sync: skipping group that failed to create",
"display_name", displayName, "external_id", gg.ExternalID, "err", err)
continue
}
}
// Delete groups no longer in Google Workspace (guard against an empty pull).
if len(gwGroups) == 0 {
logger.WarnContext(ctx, "google workspace returned no groups; skipping group deletion to avoid data loss")
return failed, nil
}
for extID, ex := range existingByExtID {
if _, ok := seen[extID]; ok {
continue
}
if err := ds.DeleteScimGroup(ctx, ex.ID); err != nil {
failed++
logger.ErrorContext(ctx, "google workspace sync: failed to delete group no longer in directory",
"scim_group_id", ex.ID, "external_id", extID, "err", err)
}
}
return failed, nil
}
// uniqueDisplayName returns a display name guaranteed not to collide with one
// already used in this sync pass, appending the group's external ID if needed.
func uniqueDisplayName(displayName, externalID string, used map[string]struct{}) string {
candidate := displayName
if candidate == "" {
candidate = externalID
}
if _, taken := used[candidate]; taken {
candidate = fmt.Sprintf("%s (%s)", candidate, externalID)
}
used[candidate] = struct{}{}
return candidate
}
func scimUserNeedsUpdate(existing, desired *fleet.ScimUser) bool {
switch {
case existing.UserName != desired.UserName:
return true
case !strPtrEqual(existing.GivenName, desired.GivenName):
return true
case !strPtrEqual(existing.FamilyName, desired.FamilyName):
return true
case !strPtrEqual(existing.Department, desired.Department):
return true
case !boolPtrEqual(existing.Active, desired.Active):
return true
case !scimEmailsEqual(existing.Emails, desired.Emails):
return true
default:
return false
}
}
func scimGroupNeedsUpdate(existing, desired *fleet.ScimGroup) bool {
if existing.DisplayName != desired.DisplayName {
return true
}
return !uintSetEqual(existing.ScimUsers, desired.ScimUsers)
}
func listAllScimUsers(ctx context.Context, ds fleet.Datastore) ([]fleet.ScimUser, error) {
var all []fleet.ScimUser
startIndex := uint(1)
for {
page, total, err := ds.ListScimUsers(ctx, fleet.ScimUsersListOptions{
ScimListOptions: fleet.ScimListOptions{StartIndex: startIndex, PerPage: scimSyncPageSize},
})
if err != nil {
return nil, err
}
all = append(all, page...)
if len(page) == 0 || uint(len(all)) >= total {
break
}
startIndex += uint(len(page))
}
return all, nil
}
func listAllScimGroups(ctx context.Context, ds fleet.Datastore) ([]fleet.ScimGroup, error) {
var all []fleet.ScimGroup
startIndex := uint(1)
for {
page, total, err := ds.ListScimGroups(ctx, fleet.ScimGroupsListOptions{
ScimListOptions: fleet.ScimListOptions{StartIndex: startIndex, PerPage: scimSyncPageSize},
})
if err != nil {
return nil, err
}
all = append(all, page...)
if len(page) == 0 || uint(len(all)) >= total {
break
}
startIndex += uint(len(page))
}
return all, nil
}
func strPtrEqual(a, b *string) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func boolPtrEqual(a, b *bool) bool {
if a == nil || b == nil {
return a == b
}
return *a == *b
}
func scimEmailsEqual(a, b []fleet.ScimUserEmail) bool {
if len(a) != len(b) {
return false
}
counts := make(map[string]int, len(a))
for _, e := range a {
counts[e.GenerateComparisonKey()]++
}
for _, e := range b {
counts[e.GenerateComparisonKey()]--
}
for _, v := range counts {
if v != 0 {
return false
}
}
return true
}
func uintSetEqual(a, b []uint) bool {
if len(a) != len(b) {
return false
}
counts := make(map[uint]int, len(a))
for _, v := range a {
counts[v]++
}
for _, v := range b {
counts[v]--
}
for _, v := range counts {
if v != 0 {
return false
}
}
return true
}