Exclude users from google workspace bridge
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
@@ -81,6 +81,7 @@ const (
|
||||
// SCIM Bridge actions
|
||||
ActionSCIMBridgeGet = "iam:scim-bridge:get"
|
||||
ActionSCIMBridgeCreate = "iam:scim-bridge:create"
|
||||
ActionSCIMBridgeUpdate = "iam:scim-bridge:update"
|
||||
ActionSCIMBridgeDelete = "iam:scim-bridge:delete"
|
||||
|
||||
// Connector actions
|
||||
|
||||
@@ -180,6 +180,11 @@ var IAMOwnerPolicy = policy.NewPolicy(
|
||||
policy.Allow("iam:scim-event:*").
|
||||
WithSID("full-scim-event-access").
|
||||
When(policy.Equals("principal.organization_id", "resource.organization_id")),
|
||||
|
||||
// Allow updating SCIM bridge settings (scoped to own organization)
|
||||
policy.Allow(ActionSCIMBridgeUpdate).
|
||||
WithSID("scim-bridge-update-access").
|
||||
When(policy.Equals("principal.organization_id", "resource.organization_id")),
|
||||
).
|
||||
WithDescription("Full IAM access for organization owners")
|
||||
|
||||
|
||||
@@ -1434,6 +1434,50 @@ func (s OrganizationService) RegenerateSCIMToken(
|
||||
return config, token, nil
|
||||
}
|
||||
|
||||
func (s OrganizationService) UpdateSCIMBridge(
|
||||
ctx context.Context,
|
||||
organizationID gid.GID,
|
||||
bridgeID gid.GID,
|
||||
excludedUserNames []string,
|
||||
) (*coredata.SCIMBridge, error) {
|
||||
bridge := &coredata.SCIMBridge{}
|
||||
scope := coredata.NewScopeFromObjectID(bridgeID)
|
||||
|
||||
err := s.pg.WithTx(
|
||||
ctx,
|
||||
func(tx pg.Conn) error {
|
||||
err := bridge.LoadByID(ctx, tx, scope, bridgeID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return fmt.Errorf("SCIM bridge not found")
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot load SCIM bridge: %w", err)
|
||||
}
|
||||
|
||||
if bridge.OrganizationID != organizationID {
|
||||
return fmt.Errorf("SCIM bridge not found")
|
||||
}
|
||||
|
||||
bridge.ExcludedUserNames = excludedUserNames
|
||||
bridge.UpdatedAt = time.Now()
|
||||
|
||||
err = bridge.Update(ctx, tx, scope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot update SCIM bridge: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return bridge, nil
|
||||
}
|
||||
|
||||
func (s OrganizationService) ListSCIMEventsByConfigID(
|
||||
ctx context.Context,
|
||||
scimConfigurationID gid.GID,
|
||||
@@ -1863,6 +1907,7 @@ func (s OrganizationService) CreateSCIMBridge(
|
||||
ConnectorID: &connectorID,
|
||||
Type: bridgeType,
|
||||
State: coredata.SCIMBridgeStateActive, // Active immediately since connector already exists
|
||||
ExcludedUserNames: []string{},
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user