Exclude users from google workspace bridge

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2026-02-09 17:13:32 +01:00
parent 96f1ca8621
commit 1494707cb5
19 changed files with 826 additions and 43 deletions

View File

@@ -28,10 +28,11 @@ import (
type (
Bridge struct {
provider provider.Provider
scimClient *scimclient.Client
forceUpdate bool
dryRun bool
provider provider.Provider
scimClient *scimclient.Client
excludedUserNames []string
forceUpdate bool
dryRun bool
}
Option func(*Bridge)
@@ -49,6 +50,12 @@ func WithForceUpdate(forceUpdate bool) Option {
}
}
func WithExcludedUserNames(excludedUserNames []string) Option {
return func(s *Bridge) {
s.excludedUserNames = excludedUserNames
}
}
func NewBridge(provider provider.Provider, scimClient *scimclient.Client, opts ...Option) *Bridge {
s := &Bridge{
provider: provider,
@@ -62,15 +69,15 @@ func NewBridge(provider provider.Provider, scimClient *scimclient.Client, opts .
return s
}
func (s *Bridge) Run(ctx context.Context) (created, updated, deactivated, skipped int, err error) {
func (s *Bridge) Run(ctx context.Context) (created, updated, deleted, deactivated, skipped int, err error) {
providerUsers, err := s.provider.ListUsers(ctx)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("cannot list provider users: %w", err)
return 0, 0, 0, 0, 0, fmt.Errorf("cannot list provider users: %w", err)
}
scimUsers, err := s.scimClient.ListUsers(ctx)
if err != nil {
return 0, 0, 0, 0, fmt.Errorf("cannot list scim users: %w", err)
return 0, 0, 0, 0, 0, fmt.Errorf("cannot list scim users: %w", err)
}
scimUsersByEmail := make(map[string]*scimclient.User)
@@ -125,6 +132,17 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deactivated, skippe
continue
}
if s.isExcluded(email) {
if !s.dryRun {
if err := s.scimClient.DeleteUser(ctx, scimUser.ID); err != nil {
errs = append(errs, fmt.Errorf("cannot delete user %q: %w", email, err))
continue
}
}
deleted++
continue
}
if !scimUser.Active {
continue
}
@@ -138,5 +156,14 @@ func (s *Bridge) Run(ctx context.Context) (created, updated, deactivated, skippe
deactivated++
}
return created, updated, deactivated, skipped, errors.Join(errs...)
return created, updated, deleted, deactivated, skipped, errors.Join(errs...)
}
func (s *Bridge) isExcluded(email string) bool {
for _, excluded := range s.excludedUserNames {
if strings.EqualFold(excluded, email) {
return true
}
}
return false
}

View File

@@ -248,6 +248,29 @@ func (c *Client) DeactivateUser(ctx context.Context, userID string) error {
return nil
}
func (c *Client) DeleteUser(ctx context.Context, userID string) error {
reqURL := fmt.Sprintf("%s/Users/%s", c.endpoint, url.PathEscape(userID))
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, reqURL, nil)
if err != nil {
return fmt.Errorf("cannot create request: %w", err)
}
c.setHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("cannot delete user: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusNotFound {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("SCIM API error: status %d, body: %s", resp.StatusCode, string(respBody))
}
return nil
}
func (c *Client) setHeaders(req *http.Request) {
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Accept", "application/scim+json")

View File

@@ -20,6 +20,7 @@ import (
"context"
"fmt"
"net/http"
"strings"
admin "google.golang.org/api/admin/directory/v1"
"google.golang.org/api/option"
@@ -31,12 +32,14 @@ import (
var _ provider.Provider = (*Provider)(nil)
type Provider struct {
httpClient *http.Client
httpClient *http.Client
excludedUserNames []string
}
func New(httpClient *http.Client) *Provider {
func New(httpClient *http.Client, excludedUserNames []string) *Provider {
return &Provider{
httpClient: httpClient,
httpClient: httpClient,
excludedUserNames: excludedUserNames,
}
}
@@ -44,6 +47,16 @@ func (p *Provider) Name() string {
return "google-workspace"
}
func (p *Provider) isExcluded(email string) bool {
emailLower := strings.ToLower(email)
for _, excluded := range p.excludedUserNames {
if strings.ToLower(excluded) == emailLower {
return true
}
}
return false
}
func (p *Provider) ListUsers(ctx context.Context) (scimclient.Users, error) {
adminService, err := admin.NewService(ctx, option.WithHTTPClient(p.httpClient))
if err != nil {
@@ -65,6 +78,10 @@ func (p *Provider) ListUsers(ctx context.Context) (scimclient.Users, error) {
}
for _, u := range resp.Users {
if p.isExcluded(u.PrimaryEmail) {
continue
}
allUsers = append(
allUsers,
scimclient.User{

View File

@@ -27,6 +27,7 @@ import (
type SyncStats struct {
Created int
Updated int
Deleted int
Deactivated int
Skipped int
}
@@ -104,6 +105,7 @@ func (r *BridgeRunner) transitionToSuccess(
log.Duration("sync_duration", duration),
log.Int("users_created", stats.Created),
log.Int("users_updated", stats.Updated),
log.Int("users_deleted", stats.Deleted),
log.Int("users_deactivated", stats.Deactivated),
log.Int("users_skipped", stats.Skipped),
)

View File

@@ -67,7 +67,7 @@ func (r *BridgeRunner) doSync(
return SyncStats{}, nil, fmt.Errorf("cannot load connector: %w", err)
}
idp, err := r.createProvider(ctx, logger, scimBridge.Type, dbConnector)
idp, err := r.createProvider(ctx, logger, scimBridge.Type, dbConnector, scimBridge.ExcludedUserNames)
if err != nil {
return SyncStats{}, nil, fmt.Errorf("cannot create provider: %w", err)
}
@@ -89,8 +89,8 @@ func (r *BridgeRunner) doSync(
}
scimClient := r.createSCIMClient(logger, token)
syncer := bridge.NewBridge(idp, scimClient)
created, updated, deactivated, skipped, err := syncer.Run(ctx)
syncer := bridge.NewBridge(idp, scimClient, bridge.WithExcludedUserNames(scimBridge.ExcludedUserNames))
created, updated, deleted, deactivated, skipped, err := syncer.Run(ctx)
if err != nil {
return SyncStats{}, nil, fmt.Errorf("sync failed: %w", err)
}
@@ -98,6 +98,7 @@ func (r *BridgeRunner) doSync(
stats := SyncStats{
Created: created,
Updated: updated,
Deleted: deleted,
Deactivated: deactivated,
Skipped: skipped,
}
@@ -121,10 +122,11 @@ func (r *BridgeRunner) createProvider(
logger *log.Logger,
bridgeType coredata.SCIMBridgeType,
dbConnector *coredata.Connector,
excludedUserNames []string,
) (provider.Provider, error) {
switch bridgeType {
case coredata.SCIMBridgeTypeGoogleWorkspace:
return r.createGoogleWorkspaceProvider(ctx, logger, dbConnector)
return r.createGoogleWorkspaceProvider(ctx, logger, dbConnector, excludedUserNames)
default:
return nil, fmt.Errorf("unsupported bridge type: %s", bridgeType)
}
@@ -134,6 +136,7 @@ func (r *BridgeRunner) createGoogleWorkspaceProvider(
ctx context.Context,
logger *log.Logger,
dbConnector *coredata.Connector,
excludedUserNames []string,
) (provider.Provider, error) {
if dbConnector.Connection == nil {
return nil, fmt.Errorf("connector has no connection configured")
@@ -161,7 +164,7 @@ func (r *BridgeRunner) createGoogleWorkspaceProvider(
if err != nil {
return nil, fmt.Errorf("cannot create HTTP client: %w", err)
}
return googleworkspace.New(httpClient), nil
return googleworkspace.New(httpClient, excludedUserNames), nil
}
httpClient, err := oauth2Conn.RefreshableClient(ctx, *refreshCfg, httpClientOpts...)
@@ -169,5 +172,5 @@ func (r *BridgeRunner) createGoogleWorkspaceProvider(
return nil, fmt.Errorf("cannot create refreshable HTTP client: %w", err)
}
return googleworkspace.New(httpClient), nil
return googleworkspace.New(httpClient, excludedUserNames), nil
}