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")}
+
+
+
+
+ );
+}
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 {