From 1494707cb5e9798abc0ebfdaa6b0056f30e7ad7e Mon Sep 17 00:00:00 2001 From: Sacha Al Himdani Date: Mon, 9 Feb 2026 17:13:32 +0100 Subject: [PATCH] Exclude users from google workspace bridge Signed-off-by: Sacha Al Himdani --- ...oogleWorkspaceConnectorFragment.graphql.ts | 14 +- ...nnectorUpdateSCIMBridgeMutation.graphql.ts | 115 ++++++ .../iam/SCIMSettingsPageQuery.graphql.ts | 13 +- .../_components/GoogleWorkspaceConnector.tsx | 135 ++++++- pkg/coredata/migrations/20260209T160000Z.sql | 3 + pkg/coredata/scim_bridge.go | 38 +- pkg/iam/iam_actions.go | 1 + pkg/iam/iam_policies.go | 5 + pkg/iam/organization_service.go | 45 +++ pkg/iam/scim/bridge/bridge.go | 43 ++- pkg/iam/scim/bridge/client/client.go | 23 ++ .../provider/googleworkspace/provider.go | 23 +- pkg/iam/scim/bridge_runner_state.go | 2 + pkg/iam/scim/bridge_runner_sync.go | 15 +- pkg/server/api/connect/v1/schema.graphql | 14 + pkg/server/api/connect/v1/schema/schema.go | 343 ++++++++++++++++++ pkg/server/api/connect/v1/types/bridge.go | 9 +- pkg/server/api/connect/v1/types/types.go | 11 + pkg/server/api/connect/v1/v1_resolver.go | 17 + 19 files changed, 826 insertions(+), 43 deletions(-) create mode 100644 apps/console/src/__generated__/iam/GoogleWorkspaceConnectorUpdateSCIMBridgeMutation.graphql.ts create mode 100644 pkg/coredata/migrations/20260209T160000Z.sql diff --git a/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts index bb21c7dce..5033b5251 100644 --- a/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts +++ b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<3c7c629a3509891b6edc98178a376c2d>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -16,6 +16,8 @@ export type GoogleWorkspaceConnectorFragment$data = { readonly createdAt: string; readonly id: string; } | null | undefined; + readonly excludedUserNames: ReadonlyArray; + readonly id: string; } | null | undefined; readonly id: string; readonly " $fragmentType": "GoogleWorkspaceConnectorFragment"; @@ -48,6 +50,14 @@ return { "name": "bridge", "plural": false, "selections": [ + (v0/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "excludedUserNames", + "storageKey": null + }, { "alias": null, "args": null, @@ -76,6 +86,6 @@ return { }; })(); -(node as any).hash = "6ce8b7f55ff2a3d0fad04ad6b452a28a"; +(node as any).hash = "4f3898fca8d93369a46b50227f3df63d"; export default node; diff --git a/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorUpdateSCIMBridgeMutation.graphql.ts b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorUpdateSCIMBridgeMutation.graphql.ts new file mode 100644 index 000000000..4075e64cf --- /dev/null +++ b/apps/console/src/__generated__/iam/GoogleWorkspaceConnectorUpdateSCIMBridgeMutation.graphql.ts @@ -0,0 +1,115 @@ +/** + * @generated SignedSource<<41b1b8127b7840bf52b967e212c286ec>> + * @lightSyntaxTransform + * @nogrep + */ + +/* tslint:disable */ +/* eslint-disable */ +// @ts-nocheck + +import { ConcreteRequest } from 'relay-runtime'; +export type UpdateSCIMBridgeInput = { + excludedUserNames: ReadonlyArray; + organizationId: string; + scimBridgeId: string; +}; +export type GoogleWorkspaceConnectorUpdateSCIMBridgeMutation$variables = { + input: UpdateSCIMBridgeInput; +}; +export type GoogleWorkspaceConnectorUpdateSCIMBridgeMutation$data = { + readonly updateSCIMBridge: { + readonly scimBridge: { + readonly excludedUserNames: ReadonlyArray; + readonly id: string; + }; + } | null | undefined; +}; +export type GoogleWorkspaceConnectorUpdateSCIMBridgeMutation = { + response: GoogleWorkspaceConnectorUpdateSCIMBridgeMutation$data; + variables: GoogleWorkspaceConnectorUpdateSCIMBridgeMutation$variables; +}; + +const node: ConcreteRequest = (function(){ +var v0 = [ + { + "defaultValue": null, + "kind": "LocalArgument", + "name": "input" + } +], +v1 = [ + { + "alias": null, + "args": [ + { + "kind": "Variable", + "name": "input", + "variableName": "input" + } + ], + "concreteType": "UpdateSCIMBridgePayload", + "kind": "LinkedField", + "name": "updateSCIMBridge", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "concreteType": "SCIMBridge", + "kind": "LinkedField", + "name": "scimBridge", + "plural": false, + "selections": [ + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "id", + "storageKey": null + }, + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "excludedUserNames", + "storageKey": null + } + ], + "storageKey": null + } + ], + "storageKey": null + } +]; +return { + "fragment": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Fragment", + "metadata": null, + "name": "GoogleWorkspaceConnectorUpdateSCIMBridgeMutation", + "selections": (v1/*: any*/), + "type": "Mutation", + "abstractKey": null + }, + "kind": "Request", + "operation": { + "argumentDefinitions": (v0/*: any*/), + "kind": "Operation", + "name": "GoogleWorkspaceConnectorUpdateSCIMBridgeMutation", + "selections": (v1/*: any*/) + }, + "params": { + "cacheID": "5b2fdb1e51ff728727c10809a89ba0ea", + "id": null, + "metadata": {}, + "name": "GoogleWorkspaceConnectorUpdateSCIMBridgeMutation", + "operationKind": "mutation", + "text": "mutation GoogleWorkspaceConnectorUpdateSCIMBridgeMutation(\n $input: UpdateSCIMBridgeInput!\n) {\n updateSCIMBridge(input: $input) {\n scimBridge {\n id\n excludedUserNames\n }\n }\n}\n" + } +}; +})(); + +(node as any).hash = "5b4046c71e69bbcc16e3fc533a5467ee"; + +export default node; diff --git a/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts b/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts index a1b05b116..39a5e59dc 100644 --- a/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts +++ b/apps/console/src/__generated__/iam/SCIMSettingsPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<68578d960865d35a2d46ee7e2c50a054>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -190,6 +190,13 @@ return { "plural": false, "selections": [ (v3/*: any*/), + { + "alias": null, + "args": null, + "kind": "ScalarField", + "name": "excludedUserNames", + "storageKey": null + }, { "alias": null, "args": null, @@ -409,12 +416,12 @@ return { ] }, "params": { - "cacheID": "e57ee79b79046f9f07c159c7079f766a", + "cacheID": "1074234a9a3734270b5da7420dea2ec6", "id": null, "metadata": {}, "name": "SCIMSettingsPageQuery", "operationKind": "query", - "text": "query SCIMSettingsPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n scimConfiguration {\n id\n bridge {\n id\n }\n ...SCIMEventListFragment\n }\n ...SCIMConfigurationFragment\n ...ConnectorListFragment\n }\n id\n }\n}\n\nfragment ConnectorListFragment on Organization {\n scimConfiguration {\n ...GoogleWorkspaceConnectorFragment\n id\n }\n}\n\nfragment GoogleWorkspaceConnectorFragment on SCIMConfiguration {\n id\n bridge {\n connector {\n id\n createdAt\n }\n id\n }\n}\n\nfragment SCIMConfigurationFragment on Organization {\n canCreateSCIMConfiguration: permission(action: \"iam:scim-configuration:create\")\n canDeleteSCIMConfiguration: permission(action: \"iam:scim-configuration:delete\")\n scimConfiguration {\n id\n endpointUrl\n bridge {\n id\n }\n }\n}\n\nfragment SCIMEventListFragment on SCIMConfiguration {\n events(first: 20) {\n edges {\n node {\n id\n ...SCIMEventListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SCIMEventListItemFragment on SCIMEvent {\n id\n method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n membership {\n id\n profile {\n fullName\n id\n }\n }\n}\n" + "text": "query SCIMSettingsPageQuery(\n $organizationId: ID!\n) {\n organization: node(id: $organizationId) {\n __typename\n ... on Organization {\n id\n scimConfiguration {\n id\n bridge {\n id\n }\n ...SCIMEventListFragment\n }\n ...SCIMConfigurationFragment\n ...ConnectorListFragment\n }\n id\n }\n}\n\nfragment ConnectorListFragment on Organization {\n scimConfiguration {\n ...GoogleWorkspaceConnectorFragment\n id\n }\n}\n\nfragment GoogleWorkspaceConnectorFragment on SCIMConfiguration {\n id\n bridge {\n id\n excludedUserNames\n connector {\n id\n createdAt\n }\n }\n}\n\nfragment SCIMConfigurationFragment on Organization {\n canCreateSCIMConfiguration: permission(action: \"iam:scim-configuration:create\")\n canDeleteSCIMConfiguration: permission(action: \"iam:scim-configuration:delete\")\n scimConfiguration {\n id\n endpointUrl\n bridge {\n id\n }\n }\n}\n\nfragment SCIMEventListFragment on SCIMConfiguration {\n events(first: 20) {\n edges {\n node {\n id\n ...SCIMEventListItemFragment\n __typename\n }\n cursor\n }\n pageInfo {\n endCursor\n hasNextPage\n hasPreviousPage\n startCursor\n }\n }\n id\n}\n\nfragment SCIMEventListItemFragment on SCIMEvent {\n id\n method\n path\n statusCode\n errorMessage\n ipAddress\n createdAt\n membership {\n id\n profile {\n fullName\n id\n }\n }\n}\n" } }; })(); diff --git a/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx b/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx index 1478e6070..8ef13ab6e 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx @@ -8,12 +8,16 @@ import { DialogContent, DialogFooter, Google, + IconSettingsGear2, + Input, useDialogRef, } from "@probo/ui"; +import { useState } from "react"; import { graphql, useFragment } from "react-relay"; import type { GoogleWorkspaceConnectorDeleteMutation } from "#/__generated__/iam/GoogleWorkspaceConnectorDeleteMutation.graphql"; import type { GoogleWorkspaceConnectorFragment$key } from "#/__generated__/iam/GoogleWorkspaceConnectorFragment.graphql"; +import type { GoogleWorkspaceConnectorUpdateSCIMBridgeMutation } from "#/__generated__/iam/GoogleWorkspaceConnectorUpdateSCIMBridgeMutation.graphql"; import { useMutationWithToasts } from "#/hooks/useMutationWithToasts"; import { useOrganizationId } from "#/hooks/useOrganizationId"; @@ -21,6 +25,8 @@ const googleWorkspaceConnectorFragment = graphql` fragment GoogleWorkspaceConnectorFragment on SCIMConfiguration { id bridge { + id + excludedUserNames connector { id createdAt @@ -39,17 +45,35 @@ const deleteSCIMConfigurationMutation = graphql` } `; +const updateSCIMBridgeMutation = graphql` + mutation GoogleWorkspaceConnectorUpdateSCIMBridgeMutation( + $input: UpdateSCIMBridgeInput! + ) { + updateSCIMBridge(input: $input) { + scimBridge { + id + excludedUserNames + } + } + } +`; + export function GoogleWorkspaceConnector(props: { fKey: GoogleWorkspaceConnectorFragment$key | null; }) { const { fKey } = props; const data = useFragment(googleWorkspaceConnectorFragment, fKey); - const connector = data?.bridge?.connector; + const bridge = data?.bridge; + const connector = bridge?.connector; const scimConfigurationId = data?.id; + const bridgeId = bridge?.id; const organizationId = useOrganizationId(); const { __, dateTimeFormat } = useTranslate(); const dialogRef = useDialogRef(); + const excludedUserNamesDialogRef = useDialogRef(); + + const [newUser, setNewUser] = useState(""); const [deleteSCIMConfiguration, isDeleting] = useMutationWithToasts( @@ -60,6 +84,15 @@ export function GoogleWorkspaceConnector(props: { }, ); + const [updateSCIMBridge, isUpdating] + = useMutationWithToasts( + updateSCIMBridgeMutation, + { + successMessage: __("Excluded user names updated successfully"), + errorMessage: __("Failed to update excluded user names"), + }, + ); + const handleConnect = () => { const baseUrl = import.meta.env.VITE_API_URL || window.location.origin; const url = new URL("/api/console/v1/connectors/initiate", baseUrl); @@ -92,6 +125,34 @@ export function GoogleWorkspaceConnector(props: { }); }; + const currentExcludedUserNames = bridge?.excludedUserNames ? [...bridge.excludedUserNames] : []; + + const saveExcludedUserNames = (newList: string[]) => { + if (!bridgeId) return; + + void updateSCIMBridge({ + variables: { + input: { + organizationId: organizationId, + scimBridgeId: bridgeId, + excludedUserNames: newList, + }, + }, + }); + }; + + const handleAddUser = () => { + const user = newUser.trim().toLowerCase(); + if (user && !currentExcludedUserNames.includes(user)) { + saveExcludedUserNames([...currentExcludedUserNames, user]); + setNewUser(""); + } + }; + + const handleRemoveUser = (user: string) => { + saveExcludedUserNames(currentExcludedUserNames.filter(e => e !== user)); + }; + // Not connected state if (!connector) { return ( @@ -130,9 +191,75 @@ export function GoogleWorkspaceConnector(props: { {__("Connected")} + + {__("Settings")} + + )} + title={__("Google Workspace Settings")} + className="max-w-lg" + > + +
+
+

{__("Excluded user names")}

+

+ {__("Users with these user names will not be synced from Google Workspace.")} +

+
+
+ setNewUser(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAddUser(); + } + }} + placeholder="user@example.com" + className="flex-1" + /> + +
+ + {currentExcludedUserNames.length > 0 && ( +
+ {currentExcludedUserNames.map((user: string) => ( +
+ {user} + +
+ ))} +
+ )} + + {currentExcludedUserNames.length === 0 && ( +

+ {__("No excluded user names. All Google Workspace users will be synced.")} +

+ )} +
+
+
+ {__("Disconnect")} )} @@ -155,7 +282,9 @@ export function GoogleWorkspaceConnector(props: { onClick={handleDisconnect} disabled={isDeleting} > - {isDeleting ? __("Disconnecting...") : __("Disconnect")} + {isDeleting + ? __("Disconnecting...") + : __("Disconnect")} diff --git a/pkg/coredata/migrations/20260209T160000Z.sql b/pkg/coredata/migrations/20260209T160000Z.sql new file mode 100644 index 000000000..29261ab3f --- /dev/null +++ b/pkg/coredata/migrations/20260209T160000Z.sql @@ -0,0 +1,3 @@ +ALTER TABLE iam_scim_bridges ADD COLUMN excluded_user_names TEXT[] NOT NULL DEFAULT '{}'; + +ALTER TABLE iam_scim_bridges ALTER COLUMN excluded_user_names DROP DEFAULT; diff --git a/pkg/coredata/scim_bridge.go b/pkg/coredata/scim_bridge.go index b7518c348..d4bb199ad 100644 --- a/pkg/coredata/scim_bridge.go +++ b/pkg/coredata/scim_bridge.go @@ -29,20 +29,21 @@ import ( type ( SCIMBridge struct { - ID gid.GID `db:"id"` - OrganizationID gid.GID `db:"organization_id"` - ScimConfigurationID gid.GID `db:"scim_configuration_id"` - ConnectorID *gid.GID `db:"connector_id"` - Type SCIMBridgeType `db:"type"` - State SCIMBridgeState `db:"state"` - LastSyncedAt *time.Time `db:"last_synced_at"` - NextSyncAt *time.Time `db:"next_sync_at"` - SyncError *string `db:"sync_error"` - ConsecutiveFailures int `db:"consecutive_failures"` - TotalSyncCount int `db:"total_sync_count"` - TotalFailureCount int `db:"total_failure_count"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` + ID gid.GID `db:"id"` + OrganizationID gid.GID `db:"organization_id"` + ScimConfigurationID gid.GID `db:"scim_configuration_id"` + ConnectorID *gid.GID `db:"connector_id"` + Type SCIMBridgeType `db:"type"` + State SCIMBridgeState `db:"state"` + ExcludedUserNames []string `db:"excluded_user_names"` + LastSyncedAt *time.Time `db:"last_synced_at"` + NextSyncAt *time.Time `db:"next_sync_at"` + SyncError *string `db:"sync_error"` + ConsecutiveFailures int `db:"consecutive_failures"` + TotalSyncCount int `db:"total_sync_count"` + TotalFailureCount int `db:"total_failure_count"` + CreatedAt time.Time `db:"created_at"` + UpdatedAt time.Time `db:"updated_at"` } SCIMBridges []*SCIMBridge @@ -87,6 +88,7 @@ SELECT connector_id, type, state, + excluded_user_names, last_synced_at, next_sync_at, sync_error, @@ -141,6 +143,7 @@ SELECT connector_id, type, state, + excluded_user_names, last_synced_at, next_sync_at, sync_error, @@ -195,6 +198,7 @@ SELECT connector_id, type, state, + excluded_user_names, last_synced_at, next_sync_at, sync_error, @@ -249,6 +253,7 @@ INSERT INTO iam_scim_bridges ( connector_id, type, state, + excluded_user_names, last_synced_at, next_sync_at, sync_error, @@ -265,6 +270,7 @@ INSERT INTO iam_scim_bridges ( @connector_id, @type, @state, + @excluded_user_names, @last_synced_at, @next_sync_at, @sync_error, @@ -284,6 +290,7 @@ INSERT INTO iam_scim_bridges ( "connector_id": s.ConnectorID, "type": s.Type, "state": s.State, + "excluded_user_names": s.ExcludedUserNames, "last_synced_at": s.LastSyncedAt, "next_sync_at": s.NextSyncAt, "sync_error": s.SyncError, @@ -312,6 +319,7 @@ UPDATE iam_scim_bridges SET connector_id = @connector_id, state = @state, + excluded_user_names = @excluded_user_names, last_synced_at = @last_synced_at, next_sync_at = @next_sync_at, sync_error = @sync_error, @@ -330,6 +338,7 @@ WHERE "id": s.ID, "connector_id": s.ConnectorID, "state": s.State, + "excluded_user_names": s.ExcludedUserNames, "last_synced_at": s.LastSyncedAt, "next_sync_at": s.NextSyncAt, "sync_error": s.SyncError, @@ -364,6 +373,7 @@ SELECT connector_id, type, state, + excluded_user_names, last_synced_at, next_sync_at, sync_error, diff --git a/pkg/iam/iam_actions.go b/pkg/iam/iam_actions.go index a614a32ba..ef5c4ffeb 100644 --- a/pkg/iam/iam_actions.go +++ b/pkg/iam/iam_actions.go @@ -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 diff --git a/pkg/iam/iam_policies.go b/pkg/iam/iam_policies.go index c3297a4ef..3b1f80db4 100644 --- a/pkg/iam/iam_policies.go +++ b/pkg/iam/iam_policies.go @@ -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") diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index cd7b75049..89c91d7d6 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -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, } diff --git a/pkg/iam/scim/bridge/bridge.go b/pkg/iam/scim/bridge/bridge.go index aca95c2df..eeecf3091 100644 --- a/pkg/iam/scim/bridge/bridge.go +++ b/pkg/iam/scim/bridge/bridge.go @@ -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 } diff --git a/pkg/iam/scim/bridge/client/client.go b/pkg/iam/scim/bridge/client/client.go index 17135c9be..0558a9418 100644 --- a/pkg/iam/scim/bridge/client/client.go +++ b/pkg/iam/scim/bridge/client/client.go @@ -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") diff --git a/pkg/iam/scim/bridge/provider/googleworkspace/provider.go b/pkg/iam/scim/bridge/provider/googleworkspace/provider.go index 07cb3e7a0..bc0cab562 100644 --- a/pkg/iam/scim/bridge/provider/googleworkspace/provider.go +++ b/pkg/iam/scim/bridge/provider/googleworkspace/provider.go @@ -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{ diff --git a/pkg/iam/scim/bridge_runner_state.go b/pkg/iam/scim/bridge_runner_state.go index 94a5eebe1..f7bbaff54 100644 --- a/pkg/iam/scim/bridge_runner_state.go +++ b/pkg/iam/scim/bridge_runner_state.go @@ -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), ) diff --git a/pkg/iam/scim/bridge_runner_sync.go b/pkg/iam/scim/bridge_runner_sync.go index 38faf48d0..88bc1f494 100644 --- a/pkg/iam/scim/bridge_runner_sync.go +++ b/pkg/iam/scim/bridge_runner_sync.go @@ -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 } diff --git a/pkg/server/api/connect/v1/schema.graphql b/pkg/server/api/connect/v1/schema.graphql index 7e59f6db5..7c67c2ee4 100644 --- a/pkg/server/api/connect/v1/schema.graphql +++ b/pkg/server/api/connect/v1/schema.graphql @@ -124,6 +124,9 @@ type Mutation { regenerateSCIMToken( input: RegenerateSCIMTokenInput! ): RegenerateSCIMTokenPayload @session(required: PRESENT) + updateSCIMBridge( + input: UpdateSCIMBridgeInput! + ): UpdateSCIMBridgePayload @session(required: PRESENT) } type Identity implements Node { @@ -368,6 +371,7 @@ type SCIMBridge implements Node { scimConfiguration: SCIMConfiguration @goField(forceResolver: true) connector: Connector @goField(forceResolver: true) type: SCIMBridgeType! + excludedUserNames: [String!]! createdAt: Datetime! updatedAt: Datetime! @@ -905,6 +909,12 @@ input RegenerateSCIMTokenInput { scimConfigurationId: ID! } +input UpdateSCIMBridgeInput { + organizationId: ID! + scimBridgeId: ID! + excludedUserNames: [String!]! +} + type CreateSCIMConfigurationPayload { scimConfiguration: SCIMConfiguration! scimBridge: SCIMBridge @@ -919,3 +929,7 @@ type RegenerateSCIMTokenPayload { scimConfiguration: SCIMConfiguration! token: String! } + +type UpdateSCIMBridgePayload { + scimBridge: SCIMBridge! +} diff --git a/pkg/server/api/connect/v1/schema/schema.go b/pkg/server/api/connect/v1/schema/schema.go index bb0d527ef..59616e368 100644 --- a/pkg/server/api/connect/v1/schema/schema.go +++ b/pkg/server/api/connect/v1/schema/schema.go @@ -246,6 +246,7 @@ type ComplexityRoot struct { UpdateMembership func(childComplexity int, input types.UpdateMembershipInput) int UpdateOrganization func(childComplexity int, input types.UpdateOrganizationInput) int UpdateSAMLConfiguration func(childComplexity int, input types.UpdateSAMLConfigurationInput) int + UpdateSCIMBridge func(childComplexity int, input types.UpdateSCIMBridgeInput) int VerifyEmail func(childComplexity int, input types.VerifyEmailInput) int } @@ -379,6 +380,7 @@ type ComplexityRoot struct { SCIMBridge struct { Connector func(childComplexity int) int CreatedAt func(childComplexity int) int + ExcludedUserNames func(childComplexity int) int ID func(childComplexity int) int Permission func(childComplexity int, action string) int ScimConfiguration func(childComplexity int) int @@ -474,6 +476,10 @@ type ComplexityRoot struct { SamlConfiguration func(childComplexity int) int } + UpdateSCIMBridgePayload struct { + ScimBridge func(childComplexity int) int + } + VerifyEmailPayload struct { Success func(childComplexity int) int } @@ -540,6 +546,7 @@ type MutationResolver interface { CreateSCIMConfiguration(ctx context.Context, input types.CreateSCIMConfigurationInput) (*types.CreateSCIMConfigurationPayload, error) DeleteSCIMConfiguration(ctx context.Context, input types.DeleteSCIMConfigurationInput) (*types.DeleteSCIMConfigurationPayload, error) RegenerateSCIMToken(ctx context.Context, input types.RegenerateSCIMTokenInput) (*types.RegenerateSCIMTokenPayload, error) + UpdateSCIMBridge(ctx context.Context, input types.UpdateSCIMBridgeInput) (*types.UpdateSCIMBridgePayload, error) } type OrganizationResolver interface { LogoURL(ctx context.Context, obj *types.Organization) (*string, error) @@ -1415,6 +1422,17 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.Mutation.UpdateSAMLConfiguration(childComplexity, args["input"].(types.UpdateSAMLConfigurationInput)), true + case "Mutation.updateSCIMBridge": + if e.complexity.Mutation.UpdateSCIMBridge == nil { + break + } + + args, err := ec.field_Mutation_updateSCIMBridge_args(ctx, rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Mutation.UpdateSCIMBridge(childComplexity, args["input"].(types.UpdateSCIMBridgeInput)), true case "Mutation.verifyEmail": if e.complexity.Mutation.VerifyEmail == nil { break @@ -1918,6 +1936,12 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin } return e.complexity.SCIMBridge.CreatedAt(childComplexity), true + case "SCIMBridge.excludedUserNames": + if e.complexity.SCIMBridge.ExcludedUserNames == nil { + break + } + + return e.complexity.SCIMBridge.ExcludedUserNames(childComplexity), true case "SCIMBridge.id": if e.complexity.SCIMBridge.ID == nil { break @@ -2264,6 +2288,13 @@ func (e *executableSchema) Complexity(ctx context.Context, typeName, field strin return e.complexity.UpdateSAMLConfigurationPayload.SamlConfiguration(childComplexity), true + case "UpdateSCIMBridgePayload.scimBridge": + if e.complexity.UpdateSCIMBridgePayload.ScimBridge == nil { + break + } + + return e.complexity.UpdateSCIMBridgePayload.ScimBridge(childComplexity), true + case "VerifyEmailPayload.success": if e.complexity.VerifyEmailPayload.Success == nil { break @@ -2310,6 +2341,7 @@ func (e *executableSchema) Exec(ctx context.Context) graphql.ResponseHandler { ec.unmarshalInputUpdateMembershipInput, ec.unmarshalInputUpdateOrganizationInput, ec.unmarshalInputUpdateSAMLConfigurationInput, + ec.unmarshalInputUpdateSCIMBridgeInput, ec.unmarshalInputVerifyEmailInput, ) first := true @@ -2534,6 +2566,9 @@ type Mutation { regenerateSCIMToken( input: RegenerateSCIMTokenInput! ): RegenerateSCIMTokenPayload @session(required: PRESENT) + updateSCIMBridge( + input: UpdateSCIMBridgeInput! + ): UpdateSCIMBridgePayload @session(required: PRESENT) } type Identity implements Node { @@ -2778,6 +2813,7 @@ type SCIMBridge implements Node { scimConfiguration: SCIMConfiguration @goField(forceResolver: true) connector: Connector @goField(forceResolver: true) type: SCIMBridgeType! + excludedUserNames: [String!]! createdAt: Datetime! updatedAt: Datetime! @@ -3315,6 +3351,12 @@ input RegenerateSCIMTokenInput { scimConfigurationId: ID! } +input UpdateSCIMBridgeInput { + organizationId: ID! + scimBridgeId: ID! + excludedUserNames: [String!]! +} + type CreateSCIMConfigurationPayload { scimConfiguration: SCIMConfiguration! scimBridge: SCIMBridge @@ -3329,6 +3371,10 @@ type RegenerateSCIMTokenPayload { scimConfiguration: SCIMConfiguration! token: String! } + +type UpdateSCIMBridgePayload { + scimBridge: SCIMBridge! +} `, BuiltIn: false}, {Name: "../../../../gqlutils/directives/session/schema.graphql", Input: `# Session directive for GraphQL APIs # Include this schema in your gqlgen configuration to enable session-based access control. @@ -3847,6 +3893,17 @@ func (ec *executionContext) field_Mutation_updateSAMLConfiguration_args(ctx cont return args, nil } +func (ec *executionContext) field_Mutation_updateSCIMBridge_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { + var err error + args := map[string]any{} + arg0, err := graphql.ProcessArgField(ctx, rawArgs, "input", ec.unmarshalNUpdateSCIMBridgeInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateSCIMBridgeInput) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} + func (ec *executionContext) field_Mutation_verifyEmail_args(ctx context.Context, rawArgs map[string]any) (map[string]any, error) { var err error args := map[string]any{} @@ -4770,6 +4827,8 @@ func (ec *executionContext) fieldContext_CreateSCIMConfigurationPayload_scimBrid return ec.fieldContext_SCIMBridge_connector(ctx, field) case "type": return ec.fieldContext_SCIMBridge_type(ctx, field) + case "excludedUserNames": + return ec.fieldContext_SCIMBridge_excludedUserNames(ctx, field) case "createdAt": return ec.fieldContext_SCIMBridge_createdAt(ctx, field) case "updatedAt": @@ -8586,6 +8645,69 @@ func (ec *executionContext) fieldContext_Mutation_regenerateSCIMToken(ctx contex return fc, nil } +func (ec *executionContext) _Mutation_updateSCIMBridge(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_Mutation_updateSCIMBridge, + func(ctx context.Context) (any, error) { + fc := graphql.GetFieldContext(ctx) + return ec.resolvers.Mutation().UpdateSCIMBridge(ctx, fc.Args["input"].(types.UpdateSCIMBridgeInput)) + }, + func(ctx context.Context, next graphql.Resolver) graphql.Resolver { + directive0 := next + + directive1 := func(ctx context.Context) (any, error) { + required, err := ec.unmarshalNSessionRequirement2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋgqlutilsᚋdirectivesᚋsessionᚐSessionRequirement(ctx, "PRESENT") + if err != nil { + var zeroVal *types.UpdateSCIMBridgePayload + return zeroVal, err + } + if ec.directives.Session == nil { + var zeroVal *types.UpdateSCIMBridgePayload + return zeroVal, errors.New("directive session is not implemented") + } + return ec.directives.Session(ctx, nil, directive0, required) + } + + next = directive1 + return next + }, + ec.marshalOUpdateSCIMBridgePayload2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateSCIMBridgePayload, + true, + false, + ) +} + +func (ec *executionContext) fieldContext_Mutation_updateSCIMBridge(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "scimBridge": + return ec.fieldContext_UpdateSCIMBridgePayload_scimBridge(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UpdateSCIMBridgePayload", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_updateSCIMBridge_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Organization_id(ctx context.Context, field graphql.CollectedField, obj *types.Organization) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -11337,6 +11459,35 @@ func (ec *executionContext) fieldContext_SCIMBridge_type(_ context.Context, fiel return fc, nil } +func (ec *executionContext) _SCIMBridge_excludedUserNames(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_SCIMBridge_excludedUserNames, + func(ctx context.Context) (any, error) { + return obj.ExcludedUserNames, nil + }, + nil, + ec.marshalNString2ᚕstringᚄ, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_SCIMBridge_excludedUserNames(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SCIMBridge", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _SCIMBridge_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.SCIMBridge) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -11667,6 +11818,8 @@ func (ec *executionContext) fieldContext_SCIMConfiguration_bridge(_ context.Cont return ec.fieldContext_SCIMBridge_connector(ctx, field) case "type": return ec.fieldContext_SCIMBridge_type(ctx, field) + case "excludedUserNames": + return ec.fieldContext_SCIMBridge_excludedUserNames(ctx, field) case "createdAt": return ec.fieldContext_SCIMBridge_createdAt(ctx, field) case "updatedAt": @@ -13217,6 +13370,55 @@ func (ec *executionContext) fieldContext_UpdateSAMLConfigurationPayload_samlConf return fc, nil } +func (ec *executionContext) _UpdateSCIMBridgePayload_scimBridge(ctx context.Context, field graphql.CollectedField, obj *types.UpdateSCIMBridgePayload) (ret graphql.Marshaler) { + return graphql.ResolveField( + ctx, + ec.OperationContext, + field, + ec.fieldContext_UpdateSCIMBridgePayload_scimBridge, + func(ctx context.Context) (any, error) { + return obj.ScimBridge, nil + }, + nil, + ec.marshalNSCIMBridge2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMBridge, + true, + true, + ) +} + +func (ec *executionContext) fieldContext_UpdateSCIMBridgePayload_scimBridge(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "UpdateSCIMBridgePayload", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SCIMBridge_id(ctx, field) + case "state": + return ec.fieldContext_SCIMBridge_state(ctx, field) + case "scimConfiguration": + return ec.fieldContext_SCIMBridge_scimConfiguration(ctx, field) + case "connector": + return ec.fieldContext_SCIMBridge_connector(ctx, field) + case "type": + return ec.fieldContext_SCIMBridge_type(ctx, field) + case "excludedUserNames": + return ec.fieldContext_SCIMBridge_excludedUserNames(ctx, field) + case "createdAt": + return ec.fieldContext_SCIMBridge_createdAt(ctx, field) + case "updatedAt": + return ec.fieldContext_SCIMBridge_updatedAt(ctx, field) + case "permission": + return ec.fieldContext_SCIMBridge_permission(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SCIMBridge", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _VerifyEmailPayload_success(ctx context.Context, field graphql.CollectedField, obj *types.VerifyEmailPayload) (ret graphql.Marshaler) { return graphql.ResolveField( ctx, @@ -15872,6 +16074,47 @@ func (ec *executionContext) unmarshalInputUpdateSAMLConfigurationInput(ctx conte return it, nil } +func (ec *executionContext) unmarshalInputUpdateSCIMBridgeInput(ctx context.Context, obj any) (types.UpdateSCIMBridgeInput, error) { + var it types.UpdateSCIMBridgeInput + asMap := map[string]any{} + for k, v := range obj.(map[string]any) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"organizationId", "scimBridgeId", "excludedUserNames"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "organizationId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("organizationId")) + data, err := ec.unmarshalNID2goᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.OrganizationID = data + case "scimBridgeId": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("scimBridgeId")) + data, err := ec.unmarshalNID2goᚗproboᚗincᚋproboᚋpkgᚋgidᚐGID(ctx, v) + if err != nil { + return it, err + } + it.ScimBridgeID = data + case "excludedUserNames": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("excludedUserNames")) + data, err := ec.unmarshalNString2ᚕstringᚄ(ctx, v) + if err != nil { + return it, err + } + it.ExcludedUserNames = data + } + } + + return it, nil +} + func (ec *executionContext) unmarshalInputVerifyEmailInput(ctx context.Context, obj any) (types.VerifyEmailInput, error) { var it types.VerifyEmailInput asMap := map[string]any{} @@ -17804,6 +18047,10 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_regenerateSCIMToken(ctx, field) }) + case "updateSCIMBridge": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_updateSCIMBridge(ctx, field) + }) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -19350,6 +19597,11 @@ func (ec *executionContext) _SCIMBridge(ctx context.Context, sel ast.SelectionSe if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } + case "excludedUserNames": + out.Values[i] = ec._SCIMBridge_excludedUserNames(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } case "createdAt": out.Values[i] = ec._SCIMBridge_createdAt(ctx, field, obj) if out.Values[i] == graphql.Null { @@ -20413,6 +20665,45 @@ func (ec *executionContext) _UpdateSAMLConfigurationPayload(ctx context.Context, return out } +var updateSCIMBridgePayloadImplementors = []string{"UpdateSCIMBridgePayload"} + +func (ec *executionContext) _UpdateSCIMBridgePayload(ctx context.Context, sel ast.SelectionSet, obj *types.UpdateSCIMBridgePayload) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, updateSCIMBridgePayloadImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("UpdateSCIMBridgePayload") + case "scimBridge": + out.Values[i] = ec._UpdateSCIMBridgePayload_scimBridge(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var verifyEmailPayloadImplementors = []string{"VerifyEmailPayload"} func (ec *executionContext) _VerifyEmailPayload(ctx context.Context, sel ast.SelectionSet, obj *types.VerifyEmailPayload) graphql.Marshaler { @@ -21575,6 +21866,16 @@ var ( } ) +func (ec *executionContext) marshalNSCIMBridge2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐSCIMBridge(ctx context.Context, sel ast.SelectionSet, v *types.SCIMBridge) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + graphql.AddErrorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._SCIMBridge(ctx, sel, v) +} + func (ec *executionContext) unmarshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState(ctx context.Context, v any) (coredata.SCIMBridgeState, error) { tmp, err := graphql.UnmarshalString(v) res := unmarshalNSCIMBridgeState2goᚗproboᚗincᚋproboᚋpkgᚋcoredataᚐSCIMBridgeState[tmp] @@ -21886,6 +22187,36 @@ func (ec *executionContext) marshalNString2string(ctx context.Context, sel ast.S return res } +func (ec *executionContext) unmarshalNString2ᚕstringᚄ(ctx context.Context, v any) ([]string, error) { + var vSlice []any + vSlice = graphql.CoerceList(v) + var err error + res := make([]string, len(vSlice)) + for i := range vSlice { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithIndex(i)) + res[i], err = ec.unmarshalNString2string(ctx, vSlice[i]) + if err != nil { + return nil, err + } + } + return res, nil +} + +func (ec *executionContext) marshalNString2ᚕstringᚄ(ctx context.Context, sel ast.SelectionSet, v []string) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + for i := range v { + ret[i] = ec.marshalNString2string(ctx, sel, v[i]) + } + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + func (ec *executionContext) unmarshalNUpdateMembershipInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateMembershipInput(ctx context.Context, v any) (types.UpdateMembershipInput, error) { res, err := ec.unmarshalInputUpdateMembershipInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -21915,6 +22246,11 @@ func (ec *executionContext) unmarshalNUpdateSAMLConfigurationInput2goᚗproboᚗ return res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) unmarshalNUpdateSCIMBridgeInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateSCIMBridgeInput(ctx context.Context, v any) (types.UpdateSCIMBridgeInput, error) { + res, err := ec.unmarshalInputUpdateSCIMBridgeInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalNVerifyEmailInput2goᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐVerifyEmailInput(ctx context.Context, v any) (types.VerifyEmailInput, error) { res, err := ec.unmarshalInputVerifyEmailInput(ctx, v) return res, graphql.ErrorOnPath(ctx, err) @@ -22695,6 +23031,13 @@ func (ec *executionContext) marshalOUpdateSAMLConfigurationPayload2ᚖgoᚗprobo return ec._UpdateSAMLConfigurationPayload(ctx, sel, v) } +func (ec *executionContext) marshalOUpdateSCIMBridgePayload2ᚖgoᚗproboᚗincᚋproboᚋpkgᚋserverᚋapiᚋconnectᚋv1ᚋtypesᚐUpdateSCIMBridgePayload(ctx context.Context, sel ast.SelectionSet, v *types.UpdateSCIMBridgePayload) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._UpdateSCIMBridgePayload(ctx, sel, v) +} + func (ec *executionContext) unmarshalOUpload2ᚖgithubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚐUpload(ctx context.Context, v any) (*graphql.Upload, error) { if v == nil { return nil, nil diff --git a/pkg/server/api/connect/v1/types/bridge.go b/pkg/server/api/connect/v1/types/bridge.go index e8ebcdb49..7b31d8fe4 100644 --- a/pkg/server/api/connect/v1/types/bridge.go +++ b/pkg/server/api/connect/v1/types/bridge.go @@ -30,9 +30,10 @@ func NewSCIMBridge(bridge *coredata.SCIMBridge) *SCIMBridge { ScimConfiguration: &SCIMConfiguration{ ID: bridge.ScimConfigurationID, }, - Connector: connector, - Type: bridge.Type, - CreatedAt: bridge.CreatedAt, - UpdatedAt: bridge.UpdatedAt, + Connector: connector, + Type: bridge.Type, + ExcludedUserNames: bridge.ExcludedUserNames, + CreatedAt: bridge.CreatedAt, + UpdatedAt: bridge.UpdatedAt, } } diff --git a/pkg/server/api/connect/v1/types/types.go b/pkg/server/api/connect/v1/types/types.go index 40b84bd35..55020ad3a 100644 --- a/pkg/server/api/connect/v1/types/types.go +++ b/pkg/server/api/connect/v1/types/types.go @@ -415,6 +415,7 @@ type SCIMBridge struct { ScimConfiguration *SCIMConfiguration `json:"scimConfiguration,omitempty"` Connector *Connector `json:"connector,omitempty"` Type coredata.SCIMBridgeType `json:"type"` + ExcludedUserNames []string `json:"excludedUserNames"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` Permission bool `json:"permission"` @@ -557,6 +558,16 @@ type UpdateSAMLConfigurationPayload struct { SamlConfiguration *SAMLConfiguration `json:"samlConfiguration,omitempty"` } +type UpdateSCIMBridgeInput struct { + OrganizationID gid.GID `json:"organizationId"` + ScimBridgeID gid.GID `json:"scimBridgeId"` + ExcludedUserNames []string `json:"excludedUserNames"` +} + +type UpdateSCIMBridgePayload struct { + ScimBridge *SCIMBridge `json:"scimBridge"` +} + type VerifyEmailInput struct { Token string `json:"token"` } diff --git a/pkg/server/api/connect/v1/v1_resolver.go b/pkg/server/api/connect/v1/v1_resolver.go index 52363a684..cf6df9232 100644 --- a/pkg/server/api/connect/v1/v1_resolver.go +++ b/pkg/server/api/connect/v1/v1_resolver.go @@ -1177,6 +1177,23 @@ func (r *mutationResolver) RegenerateSCIMToken(ctx context.Context, input types. }, nil } +// UpdateSCIMBridge is the resolver for the updateSCIMBridge field. +func (r *mutationResolver) UpdateSCIMBridge(ctx context.Context, input types.UpdateSCIMBridgeInput) (*types.UpdateSCIMBridgePayload, error) { + if err := r.authorize(ctx, input.ScimBridgeID, iam.ActionSCIMBridgeUpdate); err != nil { + return nil, err + } + + bridge, err := r.iam.OrganizationService.UpdateSCIMBridge(ctx, input.OrganizationID, input.ScimBridgeID, input.ExcludedUserNames) + if err != nil { + r.logger.ErrorCtx(ctx, "cannot update scim bridge excluded user names", log.Error(err)) + return nil, gqlutils.Internal(ctx) + } + + return &types.UpdateSCIMBridgePayload{ + ScimBridge: types.NewSCIMBridge(bridge), + }, nil +} + // LogoURL is the resolver for the logoUrl field. func (r *organizationResolver) LogoURL(ctx context.Context, obj *types.Organization) (*string, error) { if err := r.authorize(ctx, obj.ID, iam.ActionOrganizationGet, authz.WithSkipAssumptionCheck()); err != nil {