diff --git a/go.mod b/go.mod
index 4c2145a3afd93..df83d7fff62e0 100644
--- a/go.mod
+++ b/go.mod
@@ -16,6 +16,7 @@ require (
connectrpc.com/connect v1.17.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.16.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.8.0
+ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.1.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v6 v6.3.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/msi/armmsi v1.2.0
diff --git a/go.sum b/go.sum
index e0b7780b45836..95971fffbb957 100644
--- a/go.sum
+++ b/go.sum
@@ -668,6 +668,8 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLC
github.com/Azure/azure-sdk-for-go/sdk/internal v1.1.1/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0 h1:Hp+EScFOu9HeCbeW8WU2yQPJd4gGwhMgKxWe+G6jNzw=
+github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2 v2.2.0/go.mod h1:/pz8dyNQe+Ey3yBp/XuYz7oqX8YDNWVpPB0hH3XWfbc=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.1.0 h1:zDeQI/PaWztI2tcrGO/9RIMey9NvqYbnyttf/0P3QWM=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6 v6.1.0/go.mod h1:zflC9v4VfViJrSvcvplqws/yGXVbUEMZi/iHpZdSPWA=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerservice/armcontainerservice/v5 v5.0.0 h1:5n7dPVqsWfVKw+ZiEKSd3Kzu7gwBkbEBkeXb8rgaE9Q=
diff --git a/lib/cloud/azure/roleassignments.go b/lib/cloud/azure/roleassignments.go
new file mode 100644
index 0000000000000..114bceef88b96
--- /dev/null
+++ b/lib/cloud/azure/roleassignments.go
@@ -0,0 +1,57 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure
+
+import (
+ "context"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
+ "github.com/gravitational/trace"
+)
+
+// RoleAssignmentsClient wraps the Azure API to provide a high level subset of functionality
+type RoleAssignmentsClient struct {
+ cli *armauthorization.RoleAssignmentsClient
+}
+
+// NewRoleAssignmentsClient creates a new client for a given subscription and credentials
+func NewRoleAssignmentsClient(subscription string, cred azcore.TokenCredential, options *arm.ClientOptions) (*RoleAssignmentsClient, error) {
+ clientFactory, err := armauthorization.NewClientFactory(subscription, cred, options)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ roleDefCli := clientFactory.NewRoleAssignmentsClient()
+ return &RoleAssignmentsClient{cli: roleDefCli}, nil
+}
+
+// ListRoleAssignments returns role assignments for a given scope
+func (c *RoleAssignmentsClient) ListRoleAssignments(ctx context.Context, scope string) ([]*armauthorization.RoleAssignment, error) {
+ pager := c.cli.NewListForScopePager(scope, nil)
+ var roleDefs []*armauthorization.RoleAssignment
+ for pager.More() {
+ page, err := pager.NextPage(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ roleDefs = append(roleDefs, page.Value...)
+ }
+ return roleDefs, nil
+}
diff --git a/lib/cloud/azure/roledefinitions.go b/lib/cloud/azure/roledefinitions.go
new file mode 100644
index 0000000000000..cdc46196aa530
--- /dev/null
+++ b/lib/cloud/azure/roledefinitions.go
@@ -0,0 +1,57 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure
+
+import (
+ "context"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/arm"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
+ "github.com/gravitational/trace"
+)
+
+// RoleDefinitionsClient wraps the Azure API to provide a high level subset of functionality
+type RoleDefinitionsClient struct {
+ cli *armauthorization.RoleDefinitionsClient
+}
+
+// NewRoleDefinitionsClient creates a new client for a given subscription and credentials
+func NewRoleDefinitionsClient(subscription string, cred azcore.TokenCredential, options *arm.ClientOptions) (*RoleDefinitionsClient, error) {
+ clientFactory, err := armauthorization.NewClientFactory(subscription, cred, options)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ roleDefCli := clientFactory.NewRoleDefinitionsClient()
+ return &RoleDefinitionsClient{cli: roleDefCli}, nil
+}
+
+// ListRoleDefinitions returns role definitions for a given scope
+func (c *RoleDefinitionsClient) ListRoleDefinitions(ctx context.Context, scope string) ([]*armauthorization.RoleDefinition, error) {
+ pager := c.cli.NewListPager(scope, nil)
+ var roleDefs []*armauthorization.RoleDefinition
+ for pager.More() {
+ page, err := pager.NextPage(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ roleDefs = append(roleDefs, page.Value...)
+ }
+ return roleDefs, nil
+}
diff --git a/lib/cloud/clients.go b/lib/cloud/clients.go
index 93bb27f90b246..66a2fe9a5c71b 100644
--- a/lib/cloud/clients.go
+++ b/lib/cloud/clients.go
@@ -356,6 +356,10 @@ type azureClients struct {
azurePostgresFlexServersClients azure.ClientMap[azure.PostgresFlexServersClient]
// azureRunCommandClients contains the cached Azure Run Command clients.
azureRunCommandClients azure.ClientMap[azure.RunCommandClient]
+ // azureRoleDefinitionsClients contains the cached Azure Role Definitions clients.
+ azureRoleDefinitionsClients azure.ClientMap[azure.RoleDefinitionsClient]
+ // azureRoleAssignmentsClients contains the cached Azure Role Assignments clients.
+ azureRoleAssignmentsClients azure.ClientMap[azure.RoleAssignmentsClient]
}
// credentialsSource defines where the credentials must come from.
@@ -756,6 +760,16 @@ func (c *cloudClients) GetAzureRunCommandClient(subscription string) (azure.RunC
return c.azureRunCommandClients.Get(subscription, c.GetAzureCredential)
}
+// GetAzureRoleDefinitionsClient returns an Azure Role Definitions client
+func (c *cloudClients) GetAzureRoleDefinitionsClient(subscription string) (azure.RoleDefinitionsClient, error) {
+ return c.azureRoleDefinitionsClients.Get(subscription, c.GetAzureCredential)
+}
+
+// GetAzureRoleAssignmentsClient returns an Azure Role Assignments client
+func (c *cloudClients) GetAzureRoleAssignmentsClient(subscription string) (azure.RoleAssignmentsClient, error) {
+ return c.azureRoleAssignmentsClients.Get(subscription, c.GetAzureCredential)
+}
+
// Close closes all initialized clients.
func (c *cloudClients) Close() (err error) {
c.mtx.Lock()
@@ -1066,6 +1080,8 @@ type TestCloudClients struct {
AzureMySQLFlex azure.MySQLFlexServersClient
AzurePostgresFlex azure.PostgresFlexServersClient
AzureRunCommand azure.RunCommandClient
+ AzureRoleDefinitions azure.RoleDefinitionsClient
+ AzureRoleAssignments azure.RoleAssignmentsClient
}
// GetAWSSession returns AWS session for the specified region, optionally
@@ -1319,11 +1335,21 @@ func (c *TestCloudClients) GetAzurePostgresFlexServersClient(subscription string
return c.AzurePostgresFlex, nil
}
-// GetAzureRunCommand returns an Azure Run Command client for the given subscription.
+// GetAzureRunCommandClient returns an Azure Run Command client for the given subscription.
func (c *TestCloudClients) GetAzureRunCommandClient(subscription string) (azure.RunCommandClient, error) {
return c.AzureRunCommand, nil
}
+// GetAzureRoleDefinitionsClient returns an Azure Role Definitions client for the given subscription.
+func (c *TestCloudClients) GetAzureRoleDefinitionsClient(subscription string) (azure.RoleDefinitionsClient, error) {
+ return c.AzureRoleDefinitions, nil
+}
+
+// GetAzureRoleAssignmentsClient returns an Azure Role Assignments client for the given subscription.
+func (c *TestCloudClients) GetAzureRoleAssignmentsClient(subscription string) (azure.RoleAssignmentsClient, error) {
+ return c.AzureRoleAssignments, nil
+}
+
// Close closes all initialized clients.
func (c *TestCloudClients) Close() error {
return nil
diff --git a/lib/srv/discovery/fetchers/azure-sync/msggraphclient.go b/lib/srv/discovery/fetchers/azure-sync/msggraphclient.go
new file mode 100644
index 0000000000000..75d2960d7fa55
--- /dev/null
+++ b/lib/srv/discovery/fetchers/azure-sync/msggraphclient.go
@@ -0,0 +1,240 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure_sync
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+)
+
+// GraphClient represents generic MS API client
+type GraphClient struct {
+ token azcore.AccessToken
+}
+
+const (
+ usersSuffix = "users"
+ groupsSuffix = "groups"
+ servicePrincipalsSuffix = "servicePrincipals"
+ graphBaseURL = "https://graph.microsoft.com/v1.0"
+ httpTimeout = time.Second * 30
+)
+
+// graphError represents MS Graph error
+type graphError struct {
+ E struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
+// genericGraphResponse represents the utility struct for parsing MS Graph API response
+type genericGraphResponse struct {
+ Context string `json:"@odata.context"`
+ Count int `json:"@odata.count"`
+ NextLink string `json:"@odata.nextLink"`
+ Value json.RawMessage `json:"value"`
+}
+
+// User represents user resource
+type User struct {
+ ID string `json:"id"`
+ Name string `json:"displayName"`
+ MemberOf []Membership `json:"memberOf"`
+}
+
+type Membership struct {
+ Type string `json:"@odata.type"`
+ ID string `json:"id"`
+}
+
+// request represents generic request structure
+type request struct {
+ // Method HTTP method
+ Method string
+ // URL which overrides URL construction
+ URL *string
+ // Path to a resource
+ Path string
+ // Expand $expand value
+ Expand []string
+ // Filter $filter value
+ Filter string
+ // Body request body
+ Body string
+ // Response represents template structure for a response
+ Response interface{}
+ // Err represents template structure for an error
+ Err error
+ // SuccessCode http code representing success
+ SuccessCode int
+}
+
+// GetURL builds the request URL
+func (r *request) GetURL() (string, error) {
+ if r.URL != nil {
+ return *r.URL, nil
+ }
+ u, err := url.Parse(graphBaseURL)
+ if err != nil {
+ return "", err
+ }
+
+ data := url.Values{}
+ if len(r.Expand) > 0 {
+ data.Set("$expand", strings.Join(r.Expand, ","))
+ }
+ if r.Filter != "" {
+ data.Set("$filter", r.Filter)
+ }
+
+ u.Path = u.Path + "/" + r.Path
+ u.RawQuery = data.Encode()
+
+ return u.String(), nil
+}
+
+// NewGraphClient creates MS Graph API client
+func NewGraphClient(token azcore.AccessToken) *GraphClient {
+ return &GraphClient{
+ token: token,
+ }
+}
+
+// Error returns error string
+func (e graphError) Error() string {
+ return e.E.Code + " " + e.E.Message
+}
+
+func (c *GraphClient) ListUsers(ctx context.Context) ([]User, error) {
+ return c.listIdentities(ctx, usersSuffix, []string{"memberOf"})
+}
+
+func (c *GraphClient) ListGroups(ctx context.Context) ([]User, error) {
+ return c.listIdentities(ctx, groupsSuffix, []string{"memberOf"})
+}
+
+func (c *GraphClient) ListServicePrincipals(ctx context.Context) ([]User, error) {
+ return c.listIdentities(ctx, servicePrincipalsSuffix, []string{"memberOf"})
+}
+
+func (c *GraphClient) listIdentities(ctx context.Context, idType string, expand []string) ([]User, error) {
+ var users []User
+ var nextLink *string
+ for {
+ g := &genericGraphResponse{}
+ req := request{
+ Method: http.MethodGet,
+ Path: idType,
+ Expand: expand,
+ Response: &g,
+ Err: &graphError{},
+ URL: nextLink,
+ }
+ err := c.request(ctx, req)
+ if err != nil {
+ return nil, err
+ }
+ var newUsers []User
+ err = json.NewDecoder(bytes.NewReader(g.Value)).Decode(&newUsers)
+ if err != nil {
+ return nil, err
+ }
+ users = append(users, newUsers...)
+ if g.NextLink == "" {
+ break
+ }
+ nextLink = &g.NextLink
+ }
+
+ return users, nil
+}
+
+// request sends the request to the graph/bot service and returns response body as bytes slice
+func (c *GraphClient) request(ctx context.Context, req request) error {
+ reqUrl, err := req.GetURL()
+ if err != nil {
+ return err
+ }
+
+ r, err := http.NewRequestWithContext(ctx, req.Method, reqUrl, strings.NewReader(req.Body))
+ if err != nil {
+ return err
+ }
+
+ r.Header.Set("Authorization", "Bearer "+c.token.Token)
+ r.Header.Set("Content-Type", "application/json")
+
+ client := http.Client{Timeout: httpTimeout}
+ resp, err := client.Do(r)
+ if err != nil {
+ return err
+ }
+
+ defer func(r *http.Response) {
+ _ = r.Body.Close()
+ }(resp)
+
+ b, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return err
+ }
+
+ expectedCode := req.SuccessCode
+ if expectedCode == 0 {
+ expectedCode = http.StatusOK
+ }
+
+ if expectedCode == resp.StatusCode {
+ if req.Response == nil {
+ return nil
+ }
+
+ err := json.NewDecoder(bytes.NewReader(b)).Decode(req.Response)
+ if err != nil {
+ return err
+ }
+ } else {
+ if req.Err == nil {
+ return fmt.Errorf("Error requesting MS Graph API: %v", string(b))
+ }
+
+ err := json.NewDecoder(bytes.NewReader(b)).Decode(req.Err)
+ if err != nil {
+ return err
+ }
+
+ if req.Err.Error() == "" {
+ return fmt.Errorf("Error requesting MS Graph API. Expected response code was %v, but is %v", expectedCode, resp.StatusCode)
+ }
+
+ return req.Err
+ }
+
+ return nil
+}
diff --git a/lib/srv/discovery/fetchers/azure-sync/principals.go b/lib/srv/discovery/fetchers/azure-sync/principals.go
new file mode 100644
index 0000000000000..850e0cb389f71
--- /dev/null
+++ b/lib/srv/discovery/fetchers/azure-sync/principals.go
@@ -0,0 +1,82 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure_sync
+
+import (
+ "context"
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore"
+ "slices"
+
+ "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
+ "github.com/gravitational/trace"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
+)
+
+const groupType = "#microsoft.graph.group"
+const defaultGraphScope = "https://graph.microsoft.com/.default"
+
+// fetchPrincipals fetches the Azure principals (users, groups, and service principals) using the Graph API
+func fetchPrincipals(ctx context.Context, subscriptionID string, cred azcore.TokenCredential) ([]*accessgraphv1alpha.AzurePrincipal, error) {
+ // Get the graph client
+ scopes := []string{defaultGraphScope}
+ token, err := cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: scopes})
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ cli := NewGraphClient(token)
+
+ // Fetch the users, groups, and managed identities
+ users, err := cli.ListUsers(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ groups, err := cli.ListGroups(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ svcPrincipals, err := cli.ListServicePrincipals(ctx)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+ principals := slices.Concat(users, groups, svcPrincipals)
+
+ // Return the users as protobuf messages
+ pbPrincipals := make([]*accessgraphv1alpha.AzurePrincipal, 0, len(principals))
+ for _, principal := range principals {
+ // Extract group membership
+ memberOf := make([]string, 0)
+ for _, member := range principal.MemberOf {
+ if member.Type == groupType {
+ memberOf = append(memberOf, member.ID)
+ }
+ }
+ // Create the protobuf principal and append it to the list
+ pbPrincipal := &accessgraphv1alpha.AzurePrincipal{
+ Id: principal.ID,
+ SubscriptionId: subscriptionID,
+ LastSyncTime: timestamppb.Now(),
+ DisplayName: principal.Name,
+ MemberOf: memberOf,
+ }
+ pbPrincipals = append(pbPrincipals, pbPrincipal)
+ }
+ return pbPrincipals, nil
+}
diff --git a/lib/srv/discovery/fetchers/azure-sync/reconcile.go b/lib/srv/discovery/fetchers/azure-sync/reconcile.go
new file mode 100644
index 0000000000000..2b54c8cfac911
--- /dev/null
+++ b/lib/srv/discovery/fetchers/azure-sync/reconcile.go
@@ -0,0 +1,165 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure_sync
+
+import (
+ "fmt"
+
+ "google.golang.org/protobuf/proto"
+
+ accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
+ "github.com/gravitational/teleport/lib/srv/discovery/common"
+)
+
+// MergeResources merges Azure resources fetched from multiple configured Azure fetchers
+func MergeResources(results ...*Resources) *Resources {
+ if len(results) == 0 {
+ return &Resources{}
+ }
+ if len(results) == 1 {
+ return results[0]
+ }
+ result := &Resources{}
+ for _, r := range results {
+ result.Principals = append(result.Principals, r.Principals...)
+ result.RoleAssignments = append(result.RoleAssignments, r.RoleAssignments...)
+ result.RoleDefinitions = append(result.RoleDefinitions, r.RoleDefinitions...)
+ result.VirtualMachines = append(result.VirtualMachines, r.VirtualMachines...)
+ }
+ result.Principals = common.DeduplicateSlice(result.Principals, azurePrincipalsKey)
+ result.RoleAssignments = common.DeduplicateSlice(result.RoleAssignments, azureRoleAssignKey)
+ result.RoleDefinitions = common.DeduplicateSlice(result.RoleDefinitions, azureRoleDefKey)
+ result.VirtualMachines = common.DeduplicateSlice(result.VirtualMachines, azureVmKey)
+ return result
+}
+
+// newResourceList creates a new resource list message
+func newResourceList() *accessgraphv1alpha.AzureResourceList {
+ return &accessgraphv1alpha.AzureResourceList{
+ Resources: make([]*accessgraphv1alpha.AzureResource, 0),
+ }
+}
+
+// ReconcileResults compares previously and currently fetched results and determines which resources to upsert and
+// which to delete.
+func ReconcileResults(old *Resources, new *Resources) (upsert, delete *accessgraphv1alpha.AzureResourceList) {
+ upsert, delete = newResourceList(), newResourceList()
+ reconciledResources := []*reconcilePair{
+ reconcile(old.Principals, new.Principals, azurePrincipalsKey, azurePrincipalsWrap),
+ reconcile(old.RoleAssignments, new.RoleAssignments, azureRoleAssignKey, azureRoleAssignWrap),
+ reconcile(old.RoleDefinitions, new.RoleDefinitions, azureRoleDefKey, azureRoleDefWrap),
+ reconcile(old.VirtualMachines, new.VirtualMachines, azureVmKey, azureVmWrap),
+ }
+ for _, res := range reconciledResources {
+ upsert.Resources = append(upsert.Resources, res.upsert.Resources...)
+ delete.Resources = append(delete.Resources, res.delete.Resources...)
+ }
+ return upsert, delete
+}
+
+// reconcilePair contains the Azure resources to upsert and delete
+type reconcilePair struct {
+ upsert, delete *accessgraphv1alpha.AzureResourceList
+}
+
+// reconcile compares old and new items to build a list of resources to upsert and delete in the Access Graph
+func reconcile[T proto.Message](
+ oldItems []T,
+ newItems []T,
+ keyFn func(T) string,
+ wrapFn func(T) *accessgraphv1alpha.AzureResource,
+) *reconcilePair {
+ // Remove duplicates from the new items
+ newItems = common.DeduplicateSlice(newItems, keyFn)
+ upsertRes := newResourceList()
+ deleteRes := newResourceList()
+
+ // Delete all old items if there are no new items
+ if len(newItems) == 0 {
+ for _, item := range oldItems {
+ deleteRes.Resources = append(deleteRes.Resources, wrapFn(item))
+ }
+ return &reconcilePair{upsertRes, deleteRes}
+ }
+
+ // Create all new items if there are no old items
+ if len(oldItems) == 0 {
+ for _, item := range newItems {
+ upsertRes.Resources = append(upsertRes.Resources, wrapFn(item))
+ }
+ return &reconcilePair{upsertRes, deleteRes}
+ }
+
+ // Map old and new items by their key
+ oldMap := make(map[string]T, len(oldItems))
+ for _, item := range oldItems {
+ oldMap[keyFn(item)] = item
+ }
+ newMap := make(map[string]T, len(newItems))
+ for _, item := range newItems {
+ newMap[keyFn(item)] = item
+ }
+
+ // Append new or modified items to the upsert list
+ for _, item := range newItems {
+ if oldItem, ok := oldMap[keyFn(item)]; !ok || !proto.Equal(oldItem, item) {
+ upsertRes.Resources = append(upsertRes.Resources, wrapFn(item))
+ }
+ }
+
+ // Append removed items to the delete list
+ for _, item := range oldItems {
+ if _, ok := newMap[keyFn(item)]; !ok {
+ deleteRes.Resources = append(deleteRes.Resources, wrapFn(item))
+ }
+ }
+ return &reconcilePair{upsertRes, deleteRes}
+}
+
+func azurePrincipalsKey(user *accessgraphv1alpha.AzurePrincipal) string {
+ return fmt.Sprintf("%s:%s", user.SubscriptionId, user.Id)
+}
+
+func azurePrincipalsWrap(principal *accessgraphv1alpha.AzurePrincipal) *accessgraphv1alpha.AzureResource {
+ return &accessgraphv1alpha.AzureResource{Resource: &accessgraphv1alpha.AzureResource_Principal{Principal: principal}}
+}
+
+func azureRoleAssignKey(roleAssign *accessgraphv1alpha.AzureRoleAssignment) string {
+ return fmt.Sprintf("%s:%s", roleAssign.SubscriptionId, roleAssign.Id)
+}
+
+func azureRoleAssignWrap(roleAssign *accessgraphv1alpha.AzureRoleAssignment) *accessgraphv1alpha.AzureResource {
+ return &accessgraphv1alpha.AzureResource{Resource: &accessgraphv1alpha.AzureResource_RoleAssignment{RoleAssignment: roleAssign}}
+}
+
+func azureRoleDefKey(roleDef *accessgraphv1alpha.AzureRoleDefinition) string {
+ return fmt.Sprintf("%s:%s", roleDef.SubscriptionId, roleDef.Id)
+}
+
+func azureRoleDefWrap(roleDef *accessgraphv1alpha.AzureRoleDefinition) *accessgraphv1alpha.AzureResource {
+ return &accessgraphv1alpha.AzureResource{Resource: &accessgraphv1alpha.AzureResource_RoleDefinition{RoleDefinition: roleDef}}
+}
+
+func azureVmKey(vm *accessgraphv1alpha.AzureVirtualMachine) string {
+ return fmt.Sprintf("%s:%s", vm.SubscriptionId, vm.Id)
+}
+
+func azureVmWrap(vm *accessgraphv1alpha.AzureVirtualMachine) *accessgraphv1alpha.AzureResource {
+ return &accessgraphv1alpha.AzureResource{Resource: &accessgraphv1alpha.AzureResource_VirtualMachine{VirtualMachine: vm}}
+}
diff --git a/lib/srv/discovery/fetchers/azure-sync/reconcile_test.go b/lib/srv/discovery/fetchers/azure-sync/reconcile_test.go
new file mode 100644
index 0000000000000..28b293bcf1f8d
--- /dev/null
+++ b/lib/srv/discovery/fetchers/azure-sync/reconcile_test.go
@@ -0,0 +1,191 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package azure_sync
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
+)
+
+func TestReconcileResults(t *testing.T) {
+ principals := generatePrincipals()
+ roleDefs := generateRoleDefs()
+ roleAssigns := generateRoleAssigns()
+ vms := generateVms()
+
+ tests := []struct {
+ oldResults *Resources
+ newResults *Resources
+ expectedUpserts *accessgraphv1alpha.AzureResourceList
+ expectedDeletes *accessgraphv1alpha.AzureResourceList
+ }{
+ // Overlapping old and new results
+ {
+ oldResults: &Resources{
+ Principals: principals[0:2],
+ RoleDefinitions: roleDefs[0:2],
+ RoleAssignments: roleAssigns[0:2],
+ VirtualMachines: vms[0:2],
+ },
+ newResults: &Resources{
+ Principals: principals[1:3],
+ RoleDefinitions: roleDefs[1:3],
+ RoleAssignments: roleAssigns[1:3],
+ VirtualMachines: vms[1:3],
+ },
+ expectedUpserts: generateExpected(principals[2:3], roleDefs[2:3], roleAssigns[2:3], vms[2:3]),
+ expectedDeletes: generateExpected(principals[0:1], roleDefs[0:1], roleAssigns[0:1], vms[0:1]),
+ },
+ // Completely new results
+ {
+ oldResults: &Resources{
+ Principals: nil,
+ RoleDefinitions: nil,
+ RoleAssignments: nil,
+ VirtualMachines: nil,
+ },
+ newResults: &Resources{
+ Principals: principals[1:3],
+ RoleDefinitions: roleDefs[1:3],
+ RoleAssignments: roleAssigns[1:3],
+ VirtualMachines: vms[1:3],
+ },
+ expectedUpserts: generateExpected(principals[1:3], roleDefs[1:3], roleAssigns[1:3], vms[1:3]),
+ expectedDeletes: generateExpected(nil, nil, nil, nil),
+ },
+ // No new results
+ {
+ oldResults: &Resources{
+ Principals: principals[1:3],
+ RoleDefinitions: roleDefs[1:3],
+ RoleAssignments: roleAssigns[1:3],
+ VirtualMachines: vms[1:3],
+ },
+ newResults: &Resources{
+ Principals: nil,
+ RoleDefinitions: nil,
+ RoleAssignments: nil,
+ VirtualMachines: nil,
+ },
+ expectedUpserts: generateExpected(nil, nil, nil, nil),
+ expectedDeletes: generateExpected(principals[1:3], roleDefs[1:3], roleAssigns[1:3], vms[1:3]),
+ },
+ }
+
+ for _, tt := range tests {
+ upserts, deletes := ReconcileResults(tt.oldResults, tt.newResults)
+ require.ElementsMatch(t, upserts.Resources, tt.expectedUpserts.Resources)
+ require.ElementsMatch(t, deletes.Resources, tt.expectedDeletes.Resources)
+ }
+
+}
+
+func generateExpected(
+ principals []*accessgraphv1alpha.AzurePrincipal,
+ roleDefs []*accessgraphv1alpha.AzureRoleDefinition,
+ roleAssigns []*accessgraphv1alpha.AzureRoleAssignment,
+ vms []*accessgraphv1alpha.AzureVirtualMachine,
+) *accessgraphv1alpha.AzureResourceList {
+ resList := &accessgraphv1alpha.AzureResourceList{
+ Resources: make([]*accessgraphv1alpha.AzureResource, 0),
+ }
+ for _, principal := range principals {
+ resList.Resources = append(resList.Resources, azurePrincipalsWrap(principal))
+ }
+ for _, roleDef := range roleDefs {
+ resList.Resources = append(resList.Resources, azureRoleDefWrap(roleDef))
+ }
+ for _, roleAssign := range roleAssigns {
+ resList.Resources = append(resList.Resources, azureRoleAssignWrap(roleAssign))
+ }
+ for _, vm := range vms {
+ resList.Resources = append(resList.Resources, azureVmWrap(vm))
+ }
+ return resList
+}
+
+func generatePrincipals() []*accessgraphv1alpha.AzurePrincipal {
+ return []*accessgraphv1alpha.AzurePrincipal{
+ {
+ Id: "/principals/foo",
+ DisplayName: "userFoo",
+ },
+ {
+ Id: "/principals/bar",
+ DisplayName: "userBar",
+ },
+ {
+ Id: "/principals/charles",
+ DisplayName: "userCharles",
+ },
+ }
+}
+
+func generateRoleDefs() []*accessgraphv1alpha.AzureRoleDefinition {
+ return []*accessgraphv1alpha.AzureRoleDefinition{
+ {
+ Id: "/roledefinitions/foo",
+ Name: "roleFoo",
+ },
+ {
+ Id: "/roledefinitions/bar",
+ Name: "roleBar",
+ },
+ {
+ Id: "/roledefinitions/charles",
+ Name: "roleCharles",
+ },
+ }
+}
+
+func generateRoleAssigns() []*accessgraphv1alpha.AzureRoleAssignment {
+ return []*accessgraphv1alpha.AzureRoleAssignment{
+ {
+ Id: "/roleassignments/foo",
+ PrincipalId: "userFoo",
+ },
+ {
+ Id: "/roleassignments/bar",
+ PrincipalId: "userBar",
+ },
+ {
+ Id: "/roleassignments/charles",
+ PrincipalId: "userCharles",
+ },
+ }
+}
+
+func generateVms() []*accessgraphv1alpha.AzureVirtualMachine {
+ return []*accessgraphv1alpha.AzureVirtualMachine{
+ {
+ Id: "/vms/foo",
+ Name: "userFoo",
+ },
+ {
+ Id: "/vms/bar",
+ Name: "userBar",
+ },
+ {
+ Id: "/vms/charles",
+ Name: "userCharles",
+ },
+ }
+}
diff --git a/lib/srv/discovery/fetchers/azure-sync/roleassignments.go b/lib/srv/discovery/fetchers/azure-sync/roleassignments.go
new file mode 100644
index 0000000000000..58cfa89c8ae3e
--- /dev/null
+++ b/lib/srv/discovery/fetchers/azure-sync/roleassignments.go
@@ -0,0 +1,63 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure_sync
+
+import (
+ "context"
+ "fmt"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
+)
+
+// RoleAssignmentsClient specifies the methods used to fetch role assignments from Azure
+type RoleAssignmentsClient interface {
+ ListRoleAssignments(ctx context.Context, scope string) ([]*armauthorization.RoleAssignment, error)
+}
+
+// fetchRoleAssignments fetches Azure role assignments using the Azure role assignments API
+func fetchRoleAssignments(
+ ctx context.Context,
+ subscriptionID string,
+ cli RoleAssignmentsClient,
+) ([]*accessgraphv1alpha.AzureRoleAssignment, error) {
+ // List the role definitions
+ roleAssigns, err := cli.ListRoleAssignments(ctx, fmt.Sprintf("/subscriptions/%s", subscriptionID))
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ // Convert to protobuf format
+ pbRoleAssigns := make([]*accessgraphv1alpha.AzureRoleAssignment, 0, len(roleAssigns))
+ for _, roleAssign := range roleAssigns {
+ pbRoleAssign := &accessgraphv1alpha.AzureRoleAssignment{
+ Id: *roleAssign.ID,
+ SubscriptionId: subscriptionID,
+ LastSyncTime: timestamppb.Now(),
+ PrincipalId: *roleAssign.Properties.PrincipalID,
+ RoleDefinitionId: *roleAssign.Properties.RoleDefinitionID,
+ Scope: *roleAssign.Properties.Scope,
+ }
+ pbRoleAssigns = append(pbRoleAssigns, pbRoleAssign)
+ }
+ return pbRoleAssigns, nil
+}
diff --git a/lib/srv/discovery/fetchers/azure-sync/roledefinitions.go b/lib/srv/discovery/fetchers/azure-sync/roledefinitions.go
new file mode 100644
index 0000000000000..3af31524f47b0
--- /dev/null
+++ b/lib/srv/discovery/fetchers/azure-sync/roledefinitions.go
@@ -0,0 +1,77 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure_sync
+
+import (
+ "context"
+ "fmt"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
+)
+
+// RoleDefinitionsClient specifies the methods used to fetch roles from Azure
+type RoleDefinitionsClient interface {
+ ListRoleDefinitions(ctx context.Context, scope string) ([]*armauthorization.RoleDefinition, error)
+}
+
+func (a *Fetcher) fetchRoleDefinitions(
+ ctx context.Context,
+ subscriptionID string,
+ cli RoleDefinitionsClient,
+) ([]*accessgraphv1alpha.AzureRoleDefinition, error) {
+ // List the role definitions
+ roleDefs, err := cli.ListRoleDefinitions(ctx, fmt.Sprintf("/subscriptions/%s", subscriptionID))
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ // Convert to protobuf format
+ pbRoleDefs := make([]*accessgraphv1alpha.AzureRoleDefinition, 0, len(roleDefs))
+ for _, roleDef := range roleDefs {
+ pbPerms := make([]*accessgraphv1alpha.AzureRBACPermission, 0, len(roleDef.Properties.Permissions))
+ for _, perm := range roleDef.Properties.Permissions {
+ pbPerm := accessgraphv1alpha.AzureRBACPermission{
+ Actions: ptrsToList(perm.Actions),
+ NotActions: ptrsToList(perm.NotActions),
+ }
+ pbPerms = append(pbPerms, &pbPerm)
+ }
+ pbRoleDef := &accessgraphv1alpha.AzureRoleDefinition{
+ Id: *roleDef.ID,
+ Name: *roleDef.Properties.RoleName,
+ SubscriptionId: a.SubscriptionID,
+ LastSyncTime: timestamppb.Now(),
+ Permissions: pbPerms,
+ }
+ pbRoleDefs = append(pbRoleDefs, pbRoleDef)
+ }
+ return pbRoleDefs, nil
+}
+
+func ptrsToList(ptrs []*string) []string {
+ strList := make([]string, 0, len(ptrs))
+ for _, ptr := range ptrs {
+ strList = append(strList, *ptr)
+ }
+ return strList
+}
diff --git a/lib/srv/discovery/fetchers/azure-sync/virtualmachines.go b/lib/srv/discovery/fetchers/azure-sync/virtualmachines.go
new file mode 100644
index 0000000000000..39477cf096ade
--- /dev/null
+++ b/lib/srv/discovery/fetchers/azure-sync/virtualmachines.go
@@ -0,0 +1,56 @@
+/*
+ * Teleport
+ * Copyright (C) 2024 Gravitational, Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+
+package azure_sync
+
+import (
+ "context"
+ "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
+
+ "github.com/gravitational/trace"
+ "google.golang.org/protobuf/types/known/timestamppb"
+
+ accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
+)
+
+const allResourceGroups = "*"
+
+// VirtualMachinesClient specifies the methods used to fetch virtual machines from Azure
+type VirtualMachinesClient interface {
+ ListVirtualMachines(ctx context.Context, resourceGroup string) ([]*armcompute.VirtualMachine, error)
+}
+
+func fetchVirtualMachines(ctx context.Context, subscriptionID string, cli VirtualMachinesClient) ([]*accessgraphv1alpha.AzureVirtualMachine, error) {
+ vms, err := cli.ListVirtualMachines(ctx, allResourceGroups)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ // Return the VMs as protobuf messages
+ pbVms := make([]*accessgraphv1alpha.AzureVirtualMachine, 0, len(vms))
+ for _, vm := range vms {
+ pbVm := accessgraphv1alpha.AzureVirtualMachine{
+ Id: *vm.ID,
+ SubscriptionId: subscriptionID,
+ LastSyncTime: timestamppb.Now(),
+ Name: *vm.Name,
+ }
+ pbVms = append(pbVms, &pbVm)
+ }
+ return pbVms, nil
+}