From 5e55c888c43a8258abe29247ad65d9636e61c4a5 Mon Sep 17 00:00:00 2001 From: Bryan Frimin Date: Thu, 30 Apr 2026 12:57:39 +0200 Subject: [PATCH] Add Microsoft 365 SCIM bridge and access review driver Microsoft 365's native SCIM endpoint is unreliable, so mirror the Google Workspace bridge over Microsoft Graph: a new MICROSOFT_365 OAuth2 connector, a SCIM bridge provider listing /v1.0/users with $select pagination, and an access review driver that derives admin status from /directoryRoles members. Refactor the bridge runner to share OAuth2 plumbing across providers and surface the new bridge type, scopes, UI card, and bootstrap env wiring. Signed-off-by: Bryan Frimin --- .../settings/_components/ConnectorList.tsx | 8 + .../_components/GoogleWorkspaceConnector.tsx | 1 + .../_components/Microsoft365Connector.tsx | 339 ++++++++++++++++++ .../_components/AccessSourceRow.tsx | 2 + packages/ui/src/Atoms/Vendors/VendorLogo.tsx | 1 + pkg/accessreview/drivers/google_workspace.go | 6 + pkg/accessreview/drivers/microsoft_365.go | 300 ++++++++++++++++ pkg/accessreview/drivers/name_resolver.go | 65 ++++ pkg/accessreview/drivers/oauth2_scopes.go | 8 + pkg/accessreview/review_engine.go | 2 + pkg/accessreview/source_name_worker.go | 2 + pkg/bootstrap/builder.go | 12 + pkg/bootstrap/builder_test.go | 31 ++ pkg/connector/providers.go | 7 + pkg/connector/registry.go | 1 + pkg/coredata/connector_provider.go | 30 +- pkg/coredata/scim_bridge_type.go | 3 + pkg/iam/organization_service.go | 2 + .../provider/microsoft365/oauth2_scopes.go | 35 ++ .../bridge/provider/microsoft365/provider.go | 179 +++++++++ pkg/iam/scim/bridge_runner_sync.go | 22 +- .../api/connect/v1/graphql/scim.graphql | 3 + .../api/connect/v1/organization_resolvers.go | 5 + .../api/console/v1/graphql/connector.graphql | 4 + 24 files changed, 1050 insertions(+), 18 deletions(-) create mode 100644 apps/console/src/pages/iam/organizations/settings/_components/Microsoft365Connector.tsx create mode 100644 pkg/accessreview/drivers/microsoft_365.go create mode 100644 pkg/iam/scim/bridge/provider/microsoft365/oauth2_scopes.go create mode 100644 pkg/iam/scim/bridge/provider/microsoft365/provider.go diff --git a/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx b/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx index ad27a46d4..3da445f84 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/ConnectorList.tsx @@ -18,6 +18,7 @@ import { graphql, useFragment } from "react-relay"; import type { ConnectorListFragment$key } from "#/__generated__/iam/ConnectorListFragment.graphql"; import { GoogleWorkspaceConnector } from "./GoogleWorkspaceConnector"; +import { Microsoft365Connector } from "./Microsoft365Connector"; const connectorListFragment = graphql` fragment ConnectorListFragment on Organization { @@ -27,6 +28,7 @@ const connectorListFragment = graphql` } scimConfiguration { ...GoogleWorkspaceConnectorFragment + ...Microsoft365ConnectorFragment } } `; @@ -38,6 +40,8 @@ export function ConnectorList(props: { fKey: ConnectorListFragment$key }) { const googleWorkspaceScopes = data.scimBridgeTypes.find(info => info.type === "GOOGLE_WORKSPACE")?.oauth2Scopes ?? []; + const microsoft365Scopes + = data.scimBridgeTypes.find(info => info.type === "MICROSOFT_365")?.oauth2Scopes ?? []; return (
@@ -51,6 +55,10 @@ export function ConnectorList(props: { fKey: ConnectorListFragment$key }) { fKey={data.scimConfiguration ?? null} oauth2Scopes={googleWorkspaceScopes} /> +
); } 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 72376542b..b9982d242 100644 --- a/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx +++ b/apps/console/src/pages/iam/organizations/settings/_components/GoogleWorkspaceConnector.tsx @@ -264,6 +264,7 @@ export function GoogleWorkspaceConnector(props: { onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); + if (isUpdating) return; handleAddUser(); } }} diff --git a/apps/console/src/pages/iam/organizations/settings/_components/Microsoft365Connector.tsx b/apps/console/src/pages/iam/organizations/settings/_components/Microsoft365Connector.tsx new file mode 100644 index 000000000..926526d04 --- /dev/null +++ b/apps/console/src/pages/iam/organizations/settings/_components/Microsoft365Connector.tsx @@ -0,0 +1,339 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +import { sprintf } from "@probo/helpers"; +import { useTranslate } from "@probo/i18n"; +import { + Badge, + Button, + Card, + Dialog, + DialogContent, + DialogFooter, + IconSettingsGear2, + Input, + Microsoft, + useDialogRef, + useToast, +} from "@probo/ui"; +import { useState } from "react"; +import { graphql, useFragment, useMutation } from "react-relay"; + +import type { Microsoft365ConnectorDeleteMutation } from "#/__generated__/iam/Microsoft365ConnectorDeleteMutation.graphql"; +import type { Microsoft365ConnectorFragment$key } from "#/__generated__/iam/Microsoft365ConnectorFragment.graphql"; +import type { Microsoft365ConnectorUpdateSCIMBridgeMutation } from "#/__generated__/iam/Microsoft365ConnectorUpdateSCIMBridgeMutation.graphql"; +import { useOrganizationId } from "#/hooks/useOrganizationId"; + +const microsoft365ConnectorFragment = graphql` + fragment Microsoft365ConnectorFragment on SCIMConfiguration { + id + bridge { + id + excludedUserNames + connector { + id + createdAt + } + } + } +`; + +const deleteSCIMConfigurationMutation = graphql` + mutation Microsoft365ConnectorDeleteMutation( + $input: DeleteSCIMConfigurationInput! + ) { + deleteSCIMConfiguration(input: $input) { + deletedScimConfigurationId @deleteRecord + } + } +`; + +const updateSCIMBridgeMutation = graphql` + mutation Microsoft365ConnectorUpdateSCIMBridgeMutation( + $input: UpdateSCIMBridgeInput! + ) { + updateSCIMBridge(input: $input) { + scimBridge { + id + excludedUserNames + } + } + } +`; + +export function Microsoft365Connector(props: { + fKey: Microsoft365ConnectorFragment$key | null; + oauth2Scopes: readonly string[]; +}) { + const { fKey, oauth2Scopes } = props; + const data = useFragment(microsoft365ConnectorFragment, fKey); + const bridge = data?.bridge; + const connector = bridge?.connector; + const scimConfigurationId = data?.id; + const bridgeId = bridge?.id; + + const organizationId = useOrganizationId(); + const { __, dateTimeFormat } = useTranslate(); + const { toast } = useToast(); + const dialogRef = useDialogRef(); + const excludedUserNamesDialogRef = useDialogRef(); + + const [newUser, setNewUser] = useState(""); + + const [deleteSCIMConfiguration, isDeleting] + = useMutation( + deleteSCIMConfigurationMutation, + ); + + const [updateSCIMBridge, isUpdating] + = useMutation( + updateSCIMBridgeMutation, + ); + + const handleConnect = () => { + const baseUrl = import.meta.env.VITE_API_URL || window.location.origin; + const url = new URL("/api/console/v1/connectors/initiate", baseUrl); + url.searchParams.append("organization_id", organizationId); + url.searchParams.append("provider", "MICROSOFT_365"); + for (const scope of oauth2Scopes) { + url.searchParams.append("scope", scope); + } + const continueUrl = `/organizations/${organizationId}/settings/scim`; + url.searchParams.append("continue", continueUrl); + window.location.href = url.toString(); + }; + + const handleDisconnect = () => { + if (!connector || !scimConfigurationId) return; + + void deleteSCIMConfiguration({ + variables: { + input: { + organizationId: organizationId, + scimConfigurationId: scimConfigurationId, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors.map(e => e.message).join(", "), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Microsoft 365 disconnected successfully"), + variant: "success", + }); + dialogRef.current?.close(); + }, + onError(error) { + toast({ + title: __("Error"), + description: error.message, + variant: "error", + }); + }, + }); + }; + + const currentExcludedUserNames = bridge?.excludedUserNames ? [...bridge.excludedUserNames] : []; + + const saveExcludedUserNames = (newList: string[]) => { + if (!bridgeId) return; + + void updateSCIMBridge({ + variables: { + input: { + organizationId: organizationId, + scimBridgeId: bridgeId, + excludedUserNames: newList, + }, + }, + onCompleted(_, errors) { + if (errors?.length) { + toast({ + title: __("Error"), + description: errors.map(e => e.message).join(", "), + variant: "error", + }); + return; + } + toast({ + title: __("Success"), + description: __("Excluded user names updated successfully"), + variant: "success", + }); + }, + onError(error) { + toast({ + title: __("Error"), + description: error.message, + variant: "error", + }); + }, + }); + }; + + 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)); + }; + + if (!connector) { + return ( + +
+ +
+
+

{__("Microsoft 365")}

+

+ {__( + "Connect Microsoft 365 to automatically sync users via SCIM.", + )} +

+
+ +
+ ); + } + + return ( + +
+ +
+
+

{__("Microsoft 365")}

+

+ {sprintf(__("Connected on %s"), dateTimeFormat(connector.createdAt))} +

+
+ + {__("Connected")} + + + + {__("Settings")} + + )} + title={__("Microsoft 365 Settings")} + className="max-w-lg" + > + +
+
+

{__("Excluded user names")}

+

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

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

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

+ )} +
+
+
+ + {__("Disconnect")} + + )} + title={__("Disconnect Microsoft 365")} + className="max-w-lg" + > + +

+ {__( + "This will disconnect your Microsoft 365 integration. Users will no longer be automatically synced via SCIM.", + )} +

+

+ {__("This action cannot be undone.")} +

+
+ + + +
+
+ ); +} diff --git a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx index fdbc8fc70..affab3145 100644 --- a/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx +++ b/apps/console/src/pages/organizations/access-reviews/_components/AccessSourceRow.tsx @@ -106,6 +106,8 @@ function sourceLabel(connectorProvider: string | null | undefined): string { switch (connectorProvider) { case "GOOGLE_WORKSPACE": return "Google Workspace"; + case "MICROSOFT_365": + return "Microsoft 365"; case "LINEAR": return "Linear"; case "SLACK": diff --git a/packages/ui/src/Atoms/Vendors/VendorLogo.tsx b/packages/ui/src/Atoms/Vendors/VendorLogo.tsx index ceeeb3852..9b3059ed9 100644 --- a/packages/ui/src/Atoms/Vendors/VendorLogo.tsx +++ b/packages/ui/src/Atoms/Vendors/VendorLogo.tsx @@ -45,6 +45,7 @@ const vendors: Record>> = { INTERCOM: Intercom, LINEAR: Linear, MICROSOFT: Microsoft, + MICROSOFT_365: Microsoft, NOTION: Notion, ONE_PASSWORD: OnePassword, ONEPASSWORD: OnePassword, diff --git a/pkg/accessreview/drivers/google_workspace.go b/pkg/accessreview/drivers/google_workspace.go index d57e2fea0..c90670957 100644 --- a/pkg/accessreview/drivers/google_workspace.go +++ b/pkg/accessreview/drivers/google_workspace.go @@ -15,8 +15,10 @@ package drivers import ( + "bytes" "context" "fmt" + "io" "net/http" "time" @@ -67,7 +69,11 @@ func (rt *retryRoundTripper) RoundTrip(req *http.Request) (*http.Response, error return resp, nil } + // Buffer and re-attach the body so the caller can still read it + // if this turns out to be the final (retry-exhausted) response. + body, _ := io.ReadAll(resp.Body) _ = resp.Body.Close() + resp.Body = io.NopCloser(bytes.NewReader(body)) lastResp = resp backoff := time.Duration(250*(1<. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package drivers + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "go.probo.inc/probo/pkg/coredata" +) + +// Microsoft365Driver fetches user accounts from a Microsoft 365 / Microsoft +// Entra ID tenant via the Microsoft Graph API using a pre-authenticated +// HTTP client (Bearer token). +type Microsoft365Driver struct { + httpClient *http.Client +} + +var _ Driver = (*Microsoft365Driver)(nil) + +const ( + microsoft365GraphBaseURL = "https://graph.microsoft.com/v1.0" + microsoft365UsersSelect = "id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,jobTitle,department,createdDateTime" + microsoft365UsersPageSize = 999 + microsoft365MaxPaginationOK = maxPaginationPages +) + +// adminRoleDisplayNames lists the directory role display names that the +// driver treats as administrative. Microsoft splits administration across +// many roles; matching by display name keeps the driver readable. +var adminRoleDisplayNames = map[string]bool{ + "Global Administrator": true, + "Company Administrator": true, + "Privileged Role Administrator": true, + "Privileged Authentication Administrator": true, + "Security Administrator": true, + "User Administrator": true, + "Conditional Access Administrator": true, + "Compliance Administrator": true, + "Application Administrator": true, + "Cloud Application Administrator": true, + "Authentication Administrator": true, +} + +func NewMicrosoft365Driver(httpClient *http.Client) *Microsoft365Driver { + return &Microsoft365Driver{ + httpClient: &http.Client{ + Transport: &retryRoundTripper{ + next: httpClient.Transport, + maxRetries: 3, + }, + }, + } +} + +type microsoft365User struct { + ID string `json:"id"` + UserPrincipalName string `json:"userPrincipalName"` + Mail string `json:"mail"` + DisplayName string `json:"displayName"` + GivenName string `json:"givenName"` + Surname string `json:"surname"` + AccountEnabled bool `json:"accountEnabled"` + JobTitle string `json:"jobTitle"` + Department string `json:"department"` + CreatedDateTime string `json:"createdDateTime"` +} + +type microsoft365UsersPage struct { + Value []microsoft365User `json:"value"` + NextLink string `json:"@odata.nextLink"` +} + +type microsoft365DirectoryRole struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` +} + +type microsoft365RolesPage struct { + Value []microsoft365DirectoryRole `json:"value"` + NextLink string `json:"@odata.nextLink"` +} + +type microsoft365RoleMember struct { + ID string `json:"id"` + ODataType string `json:"@odata.type"` + DisplayName string `json:"displayName"` +} + +type microsoft365MembersPage struct { + Value []microsoft365RoleMember `json:"value"` + NextLink string `json:"@odata.nextLink"` +} + +func (d *Microsoft365Driver) ListAccounts(ctx context.Context) ([]AccountRecord, error) { + roles, err := d.listDirectoryRoles(ctx) + if err != nil { + return nil, fmt.Errorf("cannot list directory roles: %w", err) + } + + rolesByUser := make(map[string][]string) + for _, role := range roles { + members, err := d.listRoleMembers(ctx, role.ID) + if err != nil { + return nil, fmt.Errorf("cannot list members of role %q: %w", role.DisplayName, err) + } + for _, m := range members { + if m.ODataType != "" && m.ODataType != "#microsoft.graph.user" { + continue + } + rolesByUser[m.ID] = append(rolesByUser[m.ID], role.DisplayName) + } + } + + users, err := d.listUsers(ctx) + if err != nil { + return nil, fmt.Errorf("cannot list users: %w", err) + } + + records := make([]AccountRecord, 0, len(users)) + for _, u := range users { + email := u.Mail + if email == "" { + email = u.UserPrincipalName + } + + userRoles := rolesByUser[u.ID] + isAdmin := false + for _, r := range userRoles { + if adminRoleDisplayNames[r] { + isAdmin = true + break + } + } + + role := pickHighestRole(userRoles) + if role == "" { + role = "User" + } + + active := u.AccountEnabled + rec := AccountRecord{ + Email: email, + FullName: u.DisplayName, + Role: role, + JobTitle: u.JobTitle, + Active: &active, + IsAdmin: isAdmin, + MFAStatus: coredata.MFAStatusUnknown, + AuthMethod: coredata.AccessEntryAuthMethodSSO, + AccountType: coredata.AccessEntryAccountTypeUser, + ExternalID: u.ID, + } + + if u.CreatedDateTime != "" { + if t, err := time.Parse(time.RFC3339, u.CreatedDateTime); err == nil { + rec.CreatedAt = &t + } + } + + records = append(records, rec) + } + + return records, nil +} + +// pickHighestRole returns the most privileged admin role from the list, +// falling back to the first non-admin role when no admin role is present. +// Privilege order is hard-coded to Microsoft's well-known directory roles. +func pickHighestRole(roles []string) string { + priority := []string{ + "Global Administrator", + "Company Administrator", + "Privileged Role Administrator", + "Privileged Authentication Administrator", + "Security Administrator", + "Application Administrator", + "Cloud Application Administrator", + "User Administrator", + "Conditional Access Administrator", + "Compliance Administrator", + "Authentication Administrator", + } + + for _, p := range priority { + for _, r := range roles { + if r == p { + return r + } + } + } + + if len(roles) > 0 { + return roles[0] + } + return "" +} + +func (d *Microsoft365Driver) listUsers(ctx context.Context) ([]microsoft365User, error) { + url := fmt.Sprintf( + "%s/users?$select=%s&$top=%d", + microsoft365GraphBaseURL, + microsoft365UsersSelect, + microsoft365UsersPageSize, + ) + + var all []microsoft365User + for range microsoft365MaxPaginationOK { + var page microsoft365UsersPage + if err := d.fetchJSON(ctx, url, &page); err != nil { + return nil, err + } + all = append(all, page.Value...) + if page.NextLink == "" { + return all, nil + } + url = page.NextLink + } + + return nil, fmt.Errorf("cannot list all microsoft 365 users: %w", ErrPaginationLimitReached) +} + +func (d *Microsoft365Driver) listDirectoryRoles(ctx context.Context) ([]microsoft365DirectoryRole, error) { + url := fmt.Sprintf("%s/directoryRoles", microsoft365GraphBaseURL) + + var all []microsoft365DirectoryRole + for range microsoft365MaxPaginationOK { + var page microsoft365RolesPage + if err := d.fetchJSON(ctx, url, &page); err != nil { + return nil, err + } + all = append(all, page.Value...) + if page.NextLink == "" { + return all, nil + } + url = page.NextLink + } + + return nil, fmt.Errorf("cannot list all microsoft 365 directory roles: %w", ErrPaginationLimitReached) +} + +func (d *Microsoft365Driver) listRoleMembers(ctx context.Context, roleID string) ([]microsoft365RoleMember, error) { + url := fmt.Sprintf("%s/directoryRoles/%s/members", microsoft365GraphBaseURL, roleID) + + var all []microsoft365RoleMember + for range microsoft365MaxPaginationOK { + var page microsoft365MembersPage + if err := d.fetchJSON(ctx, url, &page); err != nil { + return nil, err + } + all = append(all, page.Value...) + if page.NextLink == "" { + return all, nil + } + url = page.NextLink + } + + return nil, fmt.Errorf("cannot list all members of role %q: %w", roleID, ErrPaginationLimitReached) +} + +func (d *Microsoft365Driver) fetchJSON(ctx context.Context, url string, dst any) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("cannot create graph request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := d.httpClient.Do(req) + if err != nil { + return fmt.Errorf("cannot execute graph request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("microsoft graph error: status %d, body: %s", resp.StatusCode, string(body)) + } + + if err := json.NewDecoder(resp.Body).Decode(dst); err != nil { + return fmt.Errorf("cannot decode graph response: %w", err) + } + + return nil +} diff --git a/pkg/accessreview/drivers/name_resolver.go b/pkg/accessreview/drivers/name_resolver.go index f779fae6d..7593a62a6 100644 --- a/pkg/accessreview/drivers/name_resolver.go +++ b/pkg/accessreview/drivers/name_resolver.go @@ -50,6 +50,7 @@ var providerDisplayNames = map[coredata.ConnectorProvider]string{ coredata.ConnectorProviderGitHub: "GitHub", coredata.ConnectorProviderIntercom: "Intercom", coredata.ConnectorProviderResend: "Resend", + coredata.ConnectorProviderMicrosoft365: "Microsoft 365", } // ProviderDisplayName returns the human-readable label for a connector provider. @@ -627,3 +628,67 @@ func (r *notionNameResolver) ResolveInstanceName(ctx context.Context) (string, e return resp.Bot.WorkspaceName, nil } + +// microsoft365NameResolver resolves the Microsoft 365 tenant display name +// via the Microsoft Graph organization endpoint. +type microsoft365NameResolver struct { + httpClient *http.Client +} + +func NewMicrosoft365NameResolver(httpClient *http.Client) NameResolver { + return µsoft365NameResolver{httpClient: httpClient} +} + +func (r *microsoft365NameResolver) ResolveInstanceName(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + "https://graph.microsoft.com/v1.0/organization?$select=displayName,verifiedDomains", + nil, + ) + if err != nil { + return "", fmt.Errorf("cannot create microsoft 365 organization request: %w", err) + } + req.Header.Set("Accept", "application/json") + + httpResp, err := r.httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("cannot execute microsoft 365 organization request: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { + return "", fmt.Errorf("cannot fetch microsoft 365 organization: unexpected status %d", httpResp.StatusCode) + } + + var resp struct { + Value []struct { + DisplayName string `json:"displayName"` + VerifiedDomains []struct { + Name string `json:"name"` + IsDefault bool `json:"isDefault"` + } `json:"verifiedDomains"` + } `json:"value"` + } + if err := json.NewDecoder(httpResp.Body).Decode(&resp); err != nil { + return "", fmt.Errorf("cannot decode microsoft 365 organization response: %w", err) + } + + if len(resp.Value) == 0 { + return "", nil + } + + org := resp.Value[0] + if org.DisplayName != "" { + return org.DisplayName, nil + } + for _, d := range org.VerifiedDomains { + if d.IsDefault { + return d.Name, nil + } + } + if len(org.VerifiedDomains) > 0 { + return org.VerifiedDomains[0].Name, nil + } + return "", nil +} diff --git a/pkg/accessreview/drivers/oauth2_scopes.go b/pkg/accessreview/drivers/oauth2_scopes.go index bc1db78a4..076623501 100644 --- a/pkg/accessreview/drivers/oauth2_scopes.go +++ b/pkg/accessreview/drivers/oauth2_scopes.go @@ -33,6 +33,14 @@ var providerOAuth2Scopes = map[coredata.ConnectorProvider][]string{ "https://www.googleapis.com/auth/admin.directory.group.member.readonly", "https://www.googleapis.com/auth/admin.directory.customer.readonly", }, + coredata.ConnectorProviderMicrosoft365: { + "openid", + "profile", + "offline_access", + "https://graph.microsoft.com/User.Read.All", + "https://graph.microsoft.com/Directory.Read.All", + "https://graph.microsoft.com/RoleManagement.Read.Directory", + }, // Notion and Intercom have no scopes here: Notion authorizes via // extra-auth-params (owner=user), Intercom configures scopes at the app // level. diff --git a/pkg/accessreview/review_engine.go b/pkg/accessreview/review_engine.go index 71c3b4d11..e3ae63cc7 100644 --- a/pkg/accessreview/review_engine.go +++ b/pkg/accessreview/review_engine.go @@ -372,6 +372,8 @@ func (e *ReviewEngine) resolveDriver( return drivers.NewIntercomDriver(httpClient), nil case coredata.ConnectorProviderResend: return drivers.NewResendDriver(httpClient), nil + case coredata.ConnectorProviderMicrosoft365: + return drivers.NewMicrosoft365Driver(httpClient), nil default: return nil, fmt.Errorf("unsupported connector provider %q for access source driver", dbConnector.Provider) } diff --git a/pkg/accessreview/source_name_worker.go b/pkg/accessreview/source_name_worker.go index 818be2619..ba77588d4 100644 --- a/pkg/accessreview/source_name_worker.go +++ b/pkg/accessreview/source_name_worker.go @@ -281,6 +281,8 @@ func (h *sourceNameHandler) buildResolver( return drivers.NewNotionNameResolver(httpClient) case coredata.ConnectorProviderResend: return drivers.NewResendNameResolver() + case coredata.ConnectorProviderMicrosoft365: + return drivers.NewMicrosoft365NameResolver(httpClient) default: return nil } diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index 7e20f40aa..2f4ae3b8c 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -336,6 +336,17 @@ func (b *Builder) Build() (*probodconfig.FullConfig, error) { }) } + if microsoft365ClientID := b.getEnv("CONNECTOR_MICROSOFT_365_CLIENT_ID"); microsoft365ClientID != "" { + cfg.Probod.Connectors = append(cfg.Probod.Connectors, probodconfig.ConnectorConfig{ + Provider: "MICROSOFT_365", + Protocol: "oauth2", + RawConfig: probodconfig.ConnectorConfigOAuth2{ + ClientID: microsoft365ClientID, + ClientSecret: b.getEnv("CONNECTOR_MICROSOFT_365_CLIENT_SECRET"), + }, + }) + } + return cfg, nil } @@ -382,6 +393,7 @@ func (b *Builder) validateRequired() error { {"CONNECTOR_INTERCOM", []string{"CLIENT_SECRET"}}, {"CONNECTOR_BREX", []string{"CLIENT_SECRET"}}, {"CONNECTOR_GOOGLE_WORKSPACE", []string{"CLIENT_SECRET"}}, + {"CONNECTOR_MICROSOFT_365", []string{"CLIENT_SECRET"}}, } for _, p := range oauthProviders { diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index df8290591..7112f2b80 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -95,6 +95,16 @@ func TestBuilder_Build_MissingRequiredEnvVars(t *testing.T) { }, wantMissing: []string{"CONNECTOR_GOOGLE_WORKSPACE_CLIENT_SECRET"}, }, + { + name: "microsoft 365 connector missing required fields", + env: map[string]string{ + "PROBOD_ENCRYPTION_KEY": "key", + "AUTH_COOKIE_SECRET": "secret", + "AUTH_PASSWORD_PEPPER": "pepper", + "CONNECTOR_MICROSOFT_365_CLIENT_ID": "client-id", + }, + wantMissing: []string{"CONNECTOR_MICROSOFT_365_CLIENT_SECRET"}, + }, } for _, tt := range tests { @@ -398,6 +408,27 @@ func TestBuilder_Build_GoogleWorkspaceConnector(t *testing.T) { assert.Equal(t, "gw-client-secret", rawConfig.ClientSecret) } +func TestBuilder_Build_Microsoft365Connector(t *testing.T) { + env := requiredEnv() + env["CONNECTOR_MICROSOFT_365_CLIENT_ID"] = "ms365-client-id" + env["CONNECTOR_MICROSOFT_365_CLIENT_SECRET"] = "ms365-client-secret" + + b := NewBuilder(mockEnv(env)) + b.samlCertificate = "test-cert" + b.samlPrivateKey = "test-key" + + cfg, err := b.Build() + require.NoError(t, err) + + require.Len(t, cfg.Probod.Connectors, 1) + connector := cfg.Probod.Connectors[0] + assert.Equal(t, "MICROSOFT_365", connector.Provider) + assert.Equal(t, "oauth2", string(connector.Protocol)) + rawConfig := connector.RawConfig.(probodconfig.ConnectorConfigOAuth2) + assert.Equal(t, "ms365-client-id", rawConfig.ClientID) + assert.Equal(t, "ms365-client-secret", rawConfig.ClientSecret) +} + func TestBuilder_Build_SlackConnector(t *testing.T) { env := requiredEnv() env["CONNECTOR_SLACK_CLIENT_ID"] = "slack-client-id" diff --git a/pkg/connector/providers.go b/pkg/connector/providers.go index 784dfd48d..e429476e7 100644 --- a/pkg/connector/providers.go +++ b/pkg/connector/providers.go @@ -80,6 +80,13 @@ var ( }, SupportsIncrementalAuth: true, }, + "MICROSOFT_365": { + AuthURL: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + TokenURL: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + ExtraAuthParams: map[string]string{ + "prompt": "consent", + }, + }, "LINEAR": { AuthURL: "https://linear.app/oauth/authorize", TokenURL: "https://api.linear.app/oauth/token", diff --git a/pkg/connector/registry.go b/pkg/connector/registry.go index d001e523a..5aa426c6f 100644 --- a/pkg/connector/registry.go +++ b/pkg/connector/registry.go @@ -136,6 +136,7 @@ var ( "TALLY": "https://api.tally.so/me", "RESEND": "https://api.resend.com/domains", "ONE_PASSWORD": "https://events.1password.com/api/v1/auditevents", + "MICROSOFT_365": "https://graph.microsoft.com/v1.0/organization?$top=1", } ) diff --git a/pkg/coredata/connector_provider.go b/pkg/coredata/connector_provider.go index 5d085cbbb..320126d2a 100644 --- a/pkg/coredata/connector_provider.go +++ b/pkg/coredata/connector_provider.go @@ -26,19 +26,20 @@ const ( ConnectorProviderGoogleWorkspace ConnectorProvider = "GOOGLE_WORKSPACE" ConnectorProviderLinear ConnectorProvider = "LINEAR" // _ ConnectorProvider = "FIGMA" — formerly Figma; removed (no driver, no OAuth config, no usage) - ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD" - ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT" - ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN" - ConnectorProviderNotion ConnectorProvider = "NOTION" - ConnectorProviderBrex ConnectorProvider = "BREX" - ConnectorProviderTally ConnectorProvider = "TALLY" - ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" - ConnectorProviderOpenAI ConnectorProvider = "OPENAI" - ConnectorProviderSentry ConnectorProvider = "SENTRY" - ConnectorProviderSupabase ConnectorProvider = "SUPABASE" - ConnectorProviderGitHub ConnectorProvider = "GITHUB" - ConnectorProviderIntercom ConnectorProvider = "INTERCOM" - ConnectorProviderResend ConnectorProvider = "RESEND" + ConnectorProviderOnePassword ConnectorProvider = "ONE_PASSWORD" + ConnectorProviderHubSpot ConnectorProvider = "HUBSPOT" + ConnectorProviderDocuSign ConnectorProvider = "DOCUSIGN" + ConnectorProviderNotion ConnectorProvider = "NOTION" + ConnectorProviderBrex ConnectorProvider = "BREX" + ConnectorProviderTally ConnectorProvider = "TALLY" + ConnectorProviderCloudflare ConnectorProvider = "CLOUDFLARE" + ConnectorProviderOpenAI ConnectorProvider = "OPENAI" + ConnectorProviderSentry ConnectorProvider = "SENTRY" + ConnectorProviderSupabase ConnectorProvider = "SUPABASE" + ConnectorProviderGitHub ConnectorProvider = "GITHUB" + ConnectorProviderIntercom ConnectorProvider = "INTERCOM" + ConnectorProviderResend ConnectorProvider = "RESEND" + ConnectorProviderMicrosoft365 ConnectorProvider = "MICROSOFT_365" ) func ConnectorProviders() []ConnectorProvider { @@ -59,6 +60,7 @@ func ConnectorProviders() []ConnectorProvider { ConnectorProviderGitHub, ConnectorProviderIntercom, ConnectorProviderResend, + ConnectorProviderMicrosoft365, } } @@ -110,6 +112,8 @@ func (cp *ConnectorProvider) Scan(value any) error { *cp = ConnectorProviderIntercom case "RESEND": *cp = ConnectorProviderResend + case "MICROSOFT_365": + *cp = ConnectorProviderMicrosoft365 default: return fmt.Errorf("invalid ConnectorProvider value: %q", s) } diff --git a/pkg/coredata/scim_bridge_type.go b/pkg/coredata/scim_bridge_type.go index c5750b02e..bfc099cfe 100644 --- a/pkg/coredata/scim_bridge_type.go +++ b/pkg/coredata/scim_bridge_type.go @@ -23,6 +23,7 @@ type SCIMBridgeType string const ( SCIMBridgeTypeGoogleWorkspace SCIMBridgeType = "GOOGLE_WORKSPACE" + SCIMBridgeTypeMicrosoft365 SCIMBridgeType = "MICROSOFT_365" ) func (t SCIMBridgeType) String() string { @@ -43,6 +44,8 @@ func (t *SCIMBridgeType) Scan(value any) error { switch str { case "GOOGLE_WORKSPACE": *t = SCIMBridgeTypeGoogleWorkspace + case "MICROSOFT_365": + *t = SCIMBridgeTypeMicrosoft365 default: return fmt.Errorf("invalid SCIMBridgeType value: %q", str) } diff --git a/pkg/iam/organization_service.go b/pkg/iam/organization_service.go index 4a361ecb0..f9afcfc12 100644 --- a/pkg/iam/organization_service.go +++ b/pkg/iam/organization_service.go @@ -2111,6 +2111,8 @@ func (s OrganizationService) CreateSCIMBridge( switch existingConnector.Provider { case coredata.ConnectorProviderGoogleWorkspace: bridgeType = coredata.SCIMBridgeTypeGoogleWorkspace + case coredata.ConnectorProviderMicrosoft365: + bridgeType = coredata.SCIMBridgeTypeMicrosoft365 default: return fmt.Errorf("connector provider %s is not supported for SCIM bridge", existingConnector.Provider) } diff --git a/pkg/iam/scim/bridge/provider/microsoft365/oauth2_scopes.go b/pkg/iam/scim/bridge/provider/microsoft365/oauth2_scopes.go new file mode 100644 index 000000000..1afdf2795 --- /dev/null +++ b/pkg/iam/scim/bridge/provider/microsoft365/oauth2_scopes.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package microsoft365 + +var ( + // OAuth2Scopes are the Microsoft Graph permission scopes required by the + // SCIM provisioning bridge. The bridge reads users (including the + // extended profile fields populated below) and their managers from the + // Microsoft Graph API. + // + // - openid / profile: identifies the consenting admin. + // - offline_access: required to receive a refresh token. + // - User.Read.All: read all user profiles in the directory. + // - Directory.Read.All: needed to read manager relationships and + // organizational data on every user without per-user consent. + OAuth2Scopes = []string{ + "openid", + "profile", + "offline_access", + "https://graph.microsoft.com/User.Read.All", + "https://graph.microsoft.com/Directory.Read.All", + } +) diff --git a/pkg/iam/scim/bridge/provider/microsoft365/provider.go b/pkg/iam/scim/bridge/provider/microsoft365/provider.go new file mode 100644 index 000000000..6d952b725 --- /dev/null +++ b/pkg/iam/scim/bridge/provider/microsoft365/provider.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +// Package microsoft365 provides a Microsoft 365 (Microsoft Entra ID) +// identity provider for SCIM synchronization using OAuth2 against the +// Microsoft Graph API. +package microsoft365 + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + scimclient "go.probo.inc/probo/pkg/iam/scim/bridge/client" + "go.probo.inc/probo/pkg/iam/scim/bridge/provider" +) + +// graphBaseURL is the Microsoft Graph v1.0 endpoint. The bridge uses raw +// HTTP rather than the official SDK because the SDK pulls in a large +// dependency tree for what is a small set of read-only calls. +const graphBaseURL = "https://graph.microsoft.com/v1.0" + +// graphUserSelect is the projection used when listing users. Limiting the +// response to only the fields we map keeps payloads small and avoids the +// extra permission scopes some properties would otherwise require. +const graphUserSelect = "id,userPrincipalName,mail,displayName,givenName,surname,accountEnabled,jobTitle,department,companyName,employeeId,preferredLanguage,usageLocation" + +// graphPageSize is the maximum page size for /users. Microsoft Graph +// caps /users at 999. +const graphPageSize = 999 + +// graphMaxPages bounds pagination to prevent unbounded loops if the +// API misbehaves. +const graphMaxPages = 1000 + +var _ provider.Provider = (*Provider)(nil) + +type Provider struct { + httpClient *http.Client + excludedUserNames []string +} + +func New(httpClient *http.Client, excludedUserNames []string) *Provider { + return &Provider{ + httpClient: httpClient, + excludedUserNames: excludedUserNames, + } +} + +func (p *Provider) Name() string { + return "microsoft-365" +} + +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 +} + +// graphUser is the subset of the Microsoft Graph user resource the +// bridge consumes. Managers are intentionally not fetched here: the +// /users endpoint does not return them and per-user lookups would +// multiply the call volume by the directory size. Manager data can be +// added later via /users/{id}/manager when needed. +type graphUser struct { + ID string `json:"id"` + UserPrincipalName string `json:"userPrincipalName"` + Mail string `json:"mail"` + DisplayName string `json:"displayName"` + GivenName string `json:"givenName"` + Surname string `json:"surname"` + AccountEnabled bool `json:"accountEnabled"` + JobTitle string `json:"jobTitle"` + Department string `json:"department"` + CompanyName string `json:"companyName"` + EmployeeID string `json:"employeeId"` + PreferredLanguage string `json:"preferredLanguage"` + UsageLocation string `json:"usageLocation"` +} + +type graphUsersResponse struct { + Value []graphUser `json:"value"` + NextLink string `json:"@odata.nextLink"` +} + +func (p *Provider) ListUsers(ctx context.Context) (scimclient.Users, error) { + url := fmt.Sprintf( + "%s/users?$select=%s&$top=%d", + graphBaseURL, + graphUserSelect, + graphPageSize, + ) + + var allUsers scimclient.Users + for range graphMaxPages { + users, next, err := p.fetchPage(ctx, url) + if err != nil { + return nil, err + } + + for _, u := range users { + email := u.Mail + if email == "" { + email = u.UserPrincipalName + } + if email == "" { + continue + } + if p.isExcluded(email) { + continue + } + + allUsers = append(allUsers, scimclient.User{ + UserName: email, + DisplayName: u.DisplayName, + GivenName: u.GivenName, + FamilyName: u.Surname, + Active: u.AccountEnabled, + ExternalID: u.ID, + Title: u.JobTitle, + Department: u.Department, + EnterpriseOrganization: u.CompanyName, + EmployeeNumber: u.EmployeeID, + PreferredLanguage: u.PreferredLanguage, + }) + } + + if next == "" { + return allUsers, nil + } + url = next + } + + return nil, fmt.Errorf("microsoft 365: pagination limit of %d pages reached", graphMaxPages) +} + +func (p *Provider) fetchPage(ctx context.Context, url string) ([]graphUser, string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, "", fmt.Errorf("cannot create graph users request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("cannot list graph users: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + body, _ := io.ReadAll(resp.Body) + return nil, "", fmt.Errorf("microsoft graph error: status %d, body: %s", resp.StatusCode, string(body)) + } + + var page graphUsersResponse + if err := json.NewDecoder(resp.Body).Decode(&page); err != nil { + return nil, "", fmt.Errorf("cannot decode graph users response: %w", err) + } + + return page.Value, page.NextLink, nil +} diff --git a/pkg/iam/scim/bridge_runner_sync.go b/pkg/iam/scim/bridge_runner_sync.go index c42dcb64b..1f51d9a9e 100644 --- a/pkg/iam/scim/bridge_runner_sync.go +++ b/pkg/iam/scim/bridge_runner_sync.go @@ -17,6 +17,7 @@ package scim import ( "context" "fmt" + "net/http" "time" "go.gearno.de/kit/httpclient" @@ -28,6 +29,7 @@ import ( scimclient "go.probo.inc/probo/pkg/iam/scim/bridge/client" "go.probo.inc/probo/pkg/iam/scim/bridge/provider" "go.probo.inc/probo/pkg/iam/scim/bridge/provider/googleworkspace" + "go.probo.inc/probo/pkg/iam/scim/bridge/provider/microsoft365" ) func (r *BridgeRunner) executeSync( @@ -152,17 +154,27 @@ func (r *BridgeRunner) createProvider( ) (provider.Provider, error) { switch bridgeType { case coredata.SCIMBridgeTypeGoogleWorkspace: - return r.createGoogleWorkspaceProvider(ctx, logger, dbConnector, excludedUserNames) + return r.createOAuth2BridgeProvider(ctx, logger, dbConnector, func(c *http.Client) provider.Provider { + return googleworkspace.New(c, excludedUserNames) + }) + case coredata.SCIMBridgeTypeMicrosoft365: + return r.createOAuth2BridgeProvider(ctx, logger, dbConnector, func(c *http.Client) provider.Provider { + return microsoft365.New(c, excludedUserNames) + }) default: return nil, fmt.Errorf("unsupported bridge type: %s", bridgeType) } } -func (r *BridgeRunner) createGoogleWorkspaceProvider( +// createOAuth2BridgeProvider builds an HTTP client (refreshable when +// supported) for an OAuth2-backed connector and hands it to the +// caller-supplied factory. All bridge providers share this scaffolding; +// only the directory API consumed differs. +func (r *BridgeRunner) createOAuth2BridgeProvider( ctx context.Context, logger *log.Logger, dbConnector *coredata.Connector, - excludedUserNames []string, + factory func(*http.Client) provider.Provider, ) (provider.Provider, error) { if dbConnector.Connection == nil { return nil, fmt.Errorf("connector has no connection configured") @@ -192,7 +204,7 @@ func (r *BridgeRunner) createGoogleWorkspaceProvider( if err != nil { return nil, fmt.Errorf("cannot create HTTP client: %w", err) } - return googleworkspace.New(httpClient, excludedUserNames), nil + return factory(httpClient), nil } httpClient, err := oauth2Conn.RefreshableClient(ctx, *refreshCfg, httpClientOpts...) @@ -200,5 +212,5 @@ func (r *BridgeRunner) createGoogleWorkspaceProvider( return nil, fmt.Errorf("cannot create refreshable HTTP client: %w", err) } - return googleworkspace.New(httpClient, excludedUserNames), nil + return factory(httpClient), nil } diff --git a/pkg/server/api/connect/v1/graphql/scim.graphql b/pkg/server/api/connect/v1/graphql/scim.graphql index cb76fbe3e..1a2771b4b 100644 --- a/pkg/server/api/connect/v1/graphql/scim.graphql +++ b/pkg/server/api/connect/v1/graphql/scim.graphql @@ -51,6 +51,8 @@ enum ConnectorProvider SLACK @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderSlack") GOOGLE_WORKSPACE @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderGoogleWorkspace") + MICROSOFT_365 + @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMicrosoft365") BREX @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderBrex") TALLY @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderTally") CLOUDFLARE @@ -60,6 +62,7 @@ enum ConnectorProvider enum SCIMBridgeType @goModel(model: "go.probo.inc/probo/pkg/coredata.SCIMBridgeType") { GOOGLE_WORKSPACE @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeTypeGoogleWorkspace") + MICROSOFT_365 @goEnum(value: "go.probo.inc/probo/pkg/coredata.SCIMBridgeTypeMicrosoft365") } type SCIMBridgeTypeInfo { diff --git a/pkg/server/api/connect/v1/organization_resolvers.go b/pkg/server/api/connect/v1/organization_resolvers.go index 6fb31402f..074dc8305 100644 --- a/pkg/server/api/connect/v1/organization_resolvers.go +++ b/pkg/server/api/connect/v1/organization_resolvers.go @@ -15,6 +15,7 @@ import ( "go.probo.inc/probo/pkg/coredata" "go.probo.inc/probo/pkg/iam" "go.probo.inc/probo/pkg/iam/scim/bridge/provider/googleworkspace" + "go.probo.inc/probo/pkg/iam/scim/bridge/provider/microsoft365" "go.probo.inc/probo/pkg/page" "go.probo.inc/probo/pkg/server/api/authn" "go.probo.inc/probo/pkg/server/api/authz" @@ -279,6 +280,10 @@ func (r *organizationResolver) ScimBridgeTypes(ctx context.Context, obj *types.O Type: coredata.SCIMBridgeTypeGoogleWorkspace, Oauth2Scopes: googleworkspace.OAuth2Scopes, }, + { + Type: coredata.SCIMBridgeTypeMicrosoft365, + Oauth2Scopes: microsoft365.OAuth2Scopes, + }, }, nil } diff --git a/pkg/server/api/console/v1/graphql/connector.graphql b/pkg/server/api/console/v1/graphql/connector.graphql index f0dc612f8..27babf9fc 100644 --- a/pkg/server/api/console/v1/graphql/connector.graphql +++ b/pkg/server/api/console/v1/graphql/connector.graphql @@ -27,6 +27,10 @@ enum ConnectorProvider INTERCOM @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderIntercom") RESEND @goEnum(value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderResend") + MICROSOFT_365 + @goEnum( + value: "go.probo.inc/probo/pkg/coredata.ConnectorProviderMicrosoft365" + ) } type ConnectorProviderInfo {