mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-24 16:17:11 +08:00
Adding Azure sync functionality which can be used by the Azure Fetcher (#50367)
* Protobuf and configuration for Access Graph Azure Discovery * Adding the Azure sync module functions along with new cloud client functionality * Moving reconciliation to the upstream azure sync PR * Moving reconciliation test to the upstream azure sync PR * Fixing rebase after protobuf gen * Updating to use existing msgraph client * PR feedback * Using variadic options * Removing memberOf expansion * Expanding memberships by calling memberOf on each user * PR feedback * Rebase go.sum stuff * Go mod tidy * Fixing go.mod * Update lib/msgraph/paginated.go Co-authored-by: Tiago Silva <tiago.silva@goteleport.com> * PR feedback * e ref update * Adding the Azure sync module functions along with new cloud client functionality * Protobuf and configuration for Access Graph Azure Discovery * Adding Azure sync functionality which can be called by the Azure fetcher * Protobuf update * Update sync process to use msgraph client * Conformant package name * Invoking membership expansion * Setting principals before expansion * Removing msgraphclient * Update e ref * Linting * PR feedback * Adding test names to reconciliation tests * Adding channel buffer * Going back to just reading from channel * Linting * PR feedback * PR feedback * PR feedback * Apply suggestions from code review Co-authored-by: Tiago Silva <tiago.silva@goteleport.com> * PR feedback * Fixing flaky test * Lint * Fix imports --------- Co-authored-by: Tiago Silva <tiago.silva@goteleport.com>
This commit is contained in:
co-authored by
Tiago Silva
parent
6181c0bed2
commit
f7ea540782
@@ -0,0 +1,251 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package azuresync
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
|
||||
"github.com/gravitational/trace"
|
||||
"golang.org/x/sync/errgroup"
|
||||
|
||||
accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
|
||||
"github.com/gravitational/teleport/lib/cloud/azure"
|
||||
"github.com/gravitational/teleport/lib/msgraph"
|
||||
"github.com/gravitational/teleport/lib/utils/slices"
|
||||
)
|
||||
|
||||
// fetcherConcurrency is an arbitrary per-resource type concurrency to ensure significant throughput. As we increase
|
||||
// the number of resource types, we may increase this value or use some other approach to fetching concurrency.
|
||||
const fetcherConcurrency = 4
|
||||
|
||||
// Config defines parameters required for fetching resources from Azure
|
||||
type Config struct {
|
||||
// SubscriptionID is the Azure subscriptipn ID
|
||||
SubscriptionID string
|
||||
// Integration is the name of the associated Teleport integration
|
||||
Integration string
|
||||
// DiscoveryConfigName is the name of this Discovery configuration
|
||||
DiscoveryConfigName string
|
||||
}
|
||||
|
||||
// Resources represents the set of resources fetched from Azure
|
||||
type Resources struct {
|
||||
// Principals are Azure users, groups, and service principals
|
||||
Principals []*accessgraphv1alpha.AzurePrincipal
|
||||
// RoleDefinitions are Azure role definitions
|
||||
RoleDefinitions []*accessgraphv1alpha.AzureRoleDefinition
|
||||
// RoleAssignments are Azure role assignments
|
||||
RoleAssignments []*accessgraphv1alpha.AzureRoleAssignment
|
||||
// VirtualMachines are Azure virtual machines
|
||||
VirtualMachines []*accessgraphv1alpha.AzureVirtualMachine
|
||||
}
|
||||
|
||||
// Fetcher provides the functionality for fetching resources from Azure
|
||||
type Fetcher struct {
|
||||
// Config is the configuration values for this fetcher
|
||||
Config
|
||||
// lastError is the last error returned from polling
|
||||
lastError error
|
||||
// lastDiscoveredResources is the number of resources last returned from polling
|
||||
lastDiscoveredResources uint64
|
||||
// lastResult is the last set of resources returned from polling
|
||||
lastResult *Resources
|
||||
|
||||
// graphClient is the MS graph client for fetching principals
|
||||
graphClient *msgraph.Client
|
||||
// roleAssignClient is the Azure client for fetching role assignments
|
||||
roleAssignClient RoleAssignmentsClient
|
||||
// roleDefClient is the Azure client for fetching role definitions
|
||||
roleDefClient RoleDefinitionsClient
|
||||
// vmClient is the Azure client for fetching virtual machines
|
||||
vmClient VirtualMachinesClient
|
||||
}
|
||||
|
||||
// NewFetcher returns a new fetcher based on configuration parameters
|
||||
func NewFetcher(cfg Config, ctx context.Context) (*Fetcher, error) {
|
||||
// Establish the credential from the managed identity
|
||||
cred, err := azidentity.NewDefaultAzureCredential(nil)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// Create the clients for the fetcher
|
||||
graphClient, err := msgraph.NewClient(msgraph.Config{
|
||||
TokenProvider: cred,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
roleAssignClient, err := azure.NewRoleAssignmentsClient(cfg.SubscriptionID, cred, nil)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
roleDefClient, err := azure.NewRoleDefinitionsClient(cfg.SubscriptionID, cred, nil)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
vmClient, err := azure.NewVirtualMachinesClient(cfg.SubscriptionID, cred, nil)
|
||||
if err != nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
|
||||
return &Fetcher{
|
||||
Config: cfg,
|
||||
lastResult: &Resources{},
|
||||
graphClient: graphClient,
|
||||
roleAssignClient: roleAssignClient,
|
||||
roleDefClient: roleDefClient,
|
||||
vmClient: vmClient,
|
||||
}, nil
|
||||
}
|
||||
|
||||
const (
|
||||
featNamePrincipals = "azure/principals"
|
||||
featNameRoleDefinitions = "azure/roledefinitions"
|
||||
featNameRoleAssignments = "azure/roleassignments"
|
||||
featNameVms = "azure/virtualmachines"
|
||||
)
|
||||
|
||||
// Features is a set of booleans that are received from the Access Graph to indicate which resources it can receive
|
||||
type Features struct {
|
||||
// Principals indicates Azure principals can be be fetched
|
||||
Principals bool
|
||||
// RoleDefinitions indicates Azure role definitions can be fetched
|
||||
RoleDefinitions bool
|
||||
// RoleAssignments indicates Azure role assignments can be fetched
|
||||
RoleAssignments bool
|
||||
// VirtualMachines indicates Azure virtual machines can be fetched
|
||||
VirtualMachines bool
|
||||
}
|
||||
|
||||
// BuildFeatures builds the feature flags based on supported types returned by Access Graph Azure endpoints.
|
||||
func BuildFeatures(values ...string) Features {
|
||||
features := Features{}
|
||||
for _, value := range values {
|
||||
switch value {
|
||||
case featNamePrincipals:
|
||||
features.Principals = true
|
||||
case featNameRoleAssignments:
|
||||
features.RoleAssignments = true
|
||||
case featNameRoleDefinitions:
|
||||
features.RoleDefinitions = true
|
||||
case featNameVms:
|
||||
features.VirtualMachines = true
|
||||
}
|
||||
}
|
||||
return features
|
||||
}
|
||||
|
||||
// Poll fetches and deduplicates Azure resources specified by the Access Graph
|
||||
func (f *Fetcher) Poll(ctx context.Context, feats Features) (*Resources, error) {
|
||||
res, err := f.fetch(ctx, feats)
|
||||
if res == nil {
|
||||
return nil, trace.Wrap(err)
|
||||
}
|
||||
res.Principals = slices.DeduplicateKey(res.Principals, azurePrincipalsKey)
|
||||
res.RoleAssignments = slices.DeduplicateKey(res.RoleAssignments, azureRoleAssignKey)
|
||||
res.RoleDefinitions = slices.DeduplicateKey(res.RoleDefinitions, azureRoleDefKey)
|
||||
res.VirtualMachines = slices.DeduplicateKey(res.VirtualMachines, azureVmKey)
|
||||
return res, trace.Wrap(err)
|
||||
}
|
||||
|
||||
// fetch returns the resources specified by the Access Graph
|
||||
func (f *Fetcher) fetch(ctx context.Context, feats Features) (*Resources, error) {
|
||||
// Accumulate Azure resources
|
||||
eg, ctx := errgroup.WithContext(ctx)
|
||||
eg.SetLimit(fetcherConcurrency)
|
||||
var result = &Resources{}
|
||||
// we use a larger value (50) here so there is always room for any returned error to be sent to errsCh without blocking.
|
||||
errsCh := make(chan error, 50)
|
||||
if feats.Principals {
|
||||
eg.Go(func() error {
|
||||
principals, err := fetchPrincipals(ctx, f.SubscriptionID, f.graphClient)
|
||||
if err != nil {
|
||||
errsCh <- err
|
||||
return nil
|
||||
}
|
||||
principals, err = expandMemberships(ctx, f.graphClient, principals)
|
||||
if err != nil {
|
||||
errsCh <- err
|
||||
return nil
|
||||
}
|
||||
result.Principals = principals
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if feats.RoleAssignments {
|
||||
eg.Go(func() error {
|
||||
roleAssigns, err := fetchRoleAssignments(ctx, f.SubscriptionID, f.roleAssignClient)
|
||||
if err != nil {
|
||||
errsCh <- err
|
||||
return nil
|
||||
}
|
||||
result.RoleAssignments = roleAssigns
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if feats.RoleDefinitions {
|
||||
eg.Go(func() error {
|
||||
roleDefs, err := fetchRoleDefinitions(ctx, f.SubscriptionID, f.roleDefClient)
|
||||
if err != nil {
|
||||
errsCh <- err
|
||||
return nil
|
||||
}
|
||||
result.RoleDefinitions = roleDefs
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if feats.VirtualMachines {
|
||||
eg.Go(func() error {
|
||||
vms, err := fetchVirtualMachines(ctx, f.SubscriptionID, f.vmClient)
|
||||
if err != nil {
|
||||
errsCh <- err
|
||||
return nil
|
||||
}
|
||||
result.VirtualMachines = vms
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Return the result along with any errors collected
|
||||
_ = eg.Wait()
|
||||
close(errsCh)
|
||||
return result, trace.NewAggregateFromChannel(errsCh, context.WithoutCancel(ctx))
|
||||
}
|
||||
|
||||
// Status returns the number of resources last fetched and/or the last fetching/reconciling error
|
||||
func (f *Fetcher) Status() (uint64, error) {
|
||||
return f.lastDiscoveredResources, f.lastError
|
||||
}
|
||||
|
||||
// DiscoveryConfigName returns the name of the configured discovery
|
||||
func (f *Fetcher) DiscoveryConfigName() string {
|
||||
return f.Config.DiscoveryConfigName
|
||||
}
|
||||
|
||||
// IsFromDiscoveryConfig returns whether the discovery is from configuration or dynamic
|
||||
func (f *Fetcher) IsFromDiscoveryConfig() bool {
|
||||
return f.Config.DiscoveryConfigName != ""
|
||||
}
|
||||
|
||||
// GetSubscriptionID returns the ID of the Azure subscription
|
||||
func (f *Fetcher) GetSubscriptionID() string {
|
||||
return f.Config.SubscriptionID
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package azuresync
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v2"
|
||||
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/compute/armcompute/v6"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testRoleDefCli struct {
|
||||
returnErr bool
|
||||
roleDefs []*armauthorization.RoleDefinition
|
||||
}
|
||||
|
||||
func (t testRoleDefCli) ListRoleDefinitions(ctx context.Context, scope string) ([]*armauthorization.RoleDefinition, error) {
|
||||
if t.returnErr {
|
||||
return nil, fmt.Errorf("error")
|
||||
}
|
||||
return t.roleDefs, nil
|
||||
}
|
||||
|
||||
type testRoleAssignCli struct {
|
||||
returnErr bool
|
||||
roleAssigns []*armauthorization.RoleAssignment
|
||||
}
|
||||
|
||||
func (t testRoleAssignCli) ListRoleAssignments(ctx context.Context, scope string) ([]*armauthorization.RoleAssignment, error) {
|
||||
if t.returnErr {
|
||||
return nil, fmt.Errorf("error")
|
||||
}
|
||||
return t.roleAssigns, nil
|
||||
}
|
||||
|
||||
type testVmCli struct {
|
||||
returnErr bool
|
||||
vms []*armcompute.VirtualMachine
|
||||
}
|
||||
|
||||
func (t testVmCli) ListVirtualMachines(ctx context.Context, resourceGroup string) ([]*armcompute.VirtualMachine, error) {
|
||||
if t.returnErr {
|
||||
return nil, fmt.Errorf("error")
|
||||
}
|
||||
return t.vms, nil
|
||||
}
|
||||
|
||||
func newRoleDef(id string, name string) *armauthorization.RoleDefinition {
|
||||
roleName := "test_role_name"
|
||||
action1 := "Microsoft.Compute/virtualMachines/read"
|
||||
action2 := "Microsoft.Compute/virtualMachines/*"
|
||||
action3 := "Microsoft.Compute/*"
|
||||
return &armauthorization.RoleDefinition{
|
||||
ID: &id,
|
||||
Name: &name,
|
||||
Properties: &armauthorization.RoleDefinitionProperties{
|
||||
Permissions: []*armauthorization.Permission{
|
||||
{
|
||||
Actions: []*string{&action1, &action2},
|
||||
},
|
||||
{
|
||||
Actions: []*string{&action3},
|
||||
},
|
||||
},
|
||||
RoleName: &roleName,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newRoleAssign(id string, name string) *armauthorization.RoleAssignment {
|
||||
scope := "test_scope"
|
||||
principalId := "test_principal_id"
|
||||
roleDefId := "test_role_def_id"
|
||||
return &armauthorization.RoleAssignment{
|
||||
ID: &id,
|
||||
Name: &name,
|
||||
Properties: &armauthorization.RoleAssignmentProperties{
|
||||
PrincipalID: &principalId,
|
||||
RoleDefinitionID: &roleDefId,
|
||||
Scope: &scope,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newVm(id string, name string) *armcompute.VirtualMachine {
|
||||
return &armcompute.VirtualMachine{
|
||||
ID: &id,
|
||||
Name: &name,
|
||||
}
|
||||
}
|
||||
|
||||
func TestPoll(t *testing.T) {
|
||||
roleDefs := []*armauthorization.RoleDefinition{
|
||||
newRoleDef("id1", "name1"),
|
||||
}
|
||||
roleAssigns := []*armauthorization.RoleAssignment{
|
||||
newRoleAssign("id1", "name1"),
|
||||
}
|
||||
vms := []*armcompute.VirtualMachine{
|
||||
newVm("id1", "name2"),
|
||||
}
|
||||
roleDefClient := testRoleDefCli{}
|
||||
roleAssignClient := testRoleAssignCli{}
|
||||
vmClient := testVmCli{}
|
||||
fetcher := Fetcher{
|
||||
Config: Config{SubscriptionID: "1234567890"},
|
||||
lastResult: &Resources{},
|
||||
roleDefClient: &roleDefClient,
|
||||
roleAssignClient: &roleAssignClient,
|
||||
vmClient: &vmClient,
|
||||
}
|
||||
ctx := context.Background()
|
||||
allFeats := Features{
|
||||
RoleDefinitions: true,
|
||||
RoleAssignments: true,
|
||||
VirtualMachines: true,
|
||||
}
|
||||
noVmsFeats := allFeats
|
||||
noVmsFeats.VirtualMachines = false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
returnErr bool
|
||||
roleDefs []*armauthorization.RoleDefinition
|
||||
roleAssigns []*armauthorization.RoleAssignment
|
||||
vms []*armcompute.VirtualMachine
|
||||
feats Features
|
||||
}{
|
||||
// Process no results from clients
|
||||
{
|
||||
name: "WithoutResults",
|
||||
returnErr: false,
|
||||
roleDefs: []*armauthorization.RoleDefinition{},
|
||||
roleAssigns: []*armauthorization.RoleAssignment{},
|
||||
vms: []*armcompute.VirtualMachine{},
|
||||
feats: allFeats,
|
||||
},
|
||||
// Process test results from clients
|
||||
{
|
||||
name: "WithResults",
|
||||
returnErr: false,
|
||||
roleDefs: roleDefs,
|
||||
roleAssigns: roleAssigns,
|
||||
vms: vms,
|
||||
feats: allFeats,
|
||||
},
|
||||
// Handle errors from clients
|
||||
{
|
||||
name: "PollErrors",
|
||||
returnErr: true,
|
||||
roleDefs: roleDefs,
|
||||
roleAssigns: roleAssigns,
|
||||
vms: vms,
|
||||
feats: allFeats,
|
||||
},
|
||||
// Handle VM features being disabled
|
||||
{
|
||||
name: "NoVmsFeats",
|
||||
returnErr: false,
|
||||
roleDefs: roleDefs,
|
||||
roleAssigns: roleAssigns,
|
||||
vms: vms,
|
||||
feats: noVmsFeats,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set the test data
|
||||
roleDefClient.returnErr = tt.returnErr
|
||||
roleDefClient.roleDefs = tt.roleDefs
|
||||
roleAssignClient.returnErr = tt.returnErr
|
||||
roleAssignClient.roleAssigns = tt.roleAssigns
|
||||
vmClient.returnErr = tt.returnErr
|
||||
vmClient.vms = tt.vms
|
||||
|
||||
// Poll for resources
|
||||
resources, err := fetcher.Poll(ctx, tt.feats)
|
||||
|
||||
// Require no error unless otherwise specified
|
||||
if tt.returnErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the results, based on the features set
|
||||
require.NotNil(t, resources)
|
||||
require.Equal(t, tt.feats.RoleDefinitions == false || len(tt.roleDefs) == 0, len(resources.RoleDefinitions) == 0)
|
||||
for idx, resource := range resources.RoleDefinitions {
|
||||
roleDef := tt.roleDefs[idx]
|
||||
require.Equal(t, *roleDef.ID, resource.Id)
|
||||
require.Equal(t, fetcher.SubscriptionID, resource.SubscriptionId)
|
||||
require.Equal(t, *roleDef.Properties.RoleName, resource.Name)
|
||||
require.Len(t, roleDef.Properties.Permissions, len(resource.Permissions))
|
||||
}
|
||||
require.Equal(t, tt.feats.RoleAssignments == false || len(tt.roleAssigns) == 0, len(resources.RoleAssignments) == 0)
|
||||
for idx, resource := range resources.RoleAssignments {
|
||||
roleAssign := tt.roleAssigns[idx]
|
||||
require.Equal(t, *roleAssign.ID, resource.Id)
|
||||
require.Equal(t, fetcher.SubscriptionID, resource.SubscriptionId)
|
||||
require.Equal(t, *roleAssign.Properties.PrincipalID, resource.PrincipalId)
|
||||
require.Equal(t, *roleAssign.Properties.RoleDefinitionID, resource.RoleDefinitionId)
|
||||
require.Equal(t, *roleAssign.Properties.Scope, resource.Scope)
|
||||
}
|
||||
require.Equal(t, tt.feats.VirtualMachines == false || len(tt.vms) == 0, len(resources.VirtualMachines) == 0)
|
||||
for idx, resource := range resources.VirtualMachines {
|
||||
vm := tt.vms[idx]
|
||||
require.Equal(t, *vm.ID, resource.Id)
|
||||
require.Equal(t, fetcher.SubscriptionID, resource.SubscriptionId)
|
||||
require.Equal(t, *vm.Name, resource.Name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package azuresync
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
accessgraphv1alpha "github.com/gravitational/teleport/gen/proto/go/accessgraph/v1alpha"
|
||||
"github.com/gravitational/teleport/lib/utils/slices"
|
||||
)
|
||||
|
||||
// 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 = slices.DeduplicateKey(result.Principals, azurePrincipalsKey)
|
||||
result.RoleAssignments = slices.DeduplicateKey(result.RoleAssignments, azureRoleAssignKey)
|
||||
result.RoleDefinitions = slices.DeduplicateKey(result.RoleDefinitions, azureRoleDefKey)
|
||||
result.VirtualMachines = slices.DeduplicateKey(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 = slices.DeduplicateKey(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}}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Teleport
|
||||
* Copyright (C) 2025 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 <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package azuresync
|
||||
|
||||
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 {
|
||||
name string
|
||||
oldResults *Resources
|
||||
newResults *Resources
|
||||
expectedUpserts *accessgraphv1alpha.AzureResourceList
|
||||
expectedDeletes *accessgraphv1alpha.AzureResourceList
|
||||
}{
|
||||
// Overlapping old and new results
|
||||
{
|
||||
name: "OverlapOldAndNewResults",
|
||||
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
|
||||
{
|
||||
name: "CompletelyNewResults",
|
||||
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
|
||||
{
|
||||
name: "NoNewResults",
|
||||
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 {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
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",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -57,3 +57,17 @@ func FromPointers[T any](in []*T) []T {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DeduplicateKey returns a deduplicated slice by comparing key values from the key function
|
||||
func DeduplicateKey[T any](s []T, key func(T) string) []T {
|
||||
out := make([]T, 0, len(s))
|
||||
seen := make(map[string]struct{})
|
||||
for _, v := range s {
|
||||
if _, ok := seen[key(v)]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key(v)] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
package slices
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -92,3 +93,92 @@ func TestFilterMapUnique(t *testing.T) {
|
||||
require.Equal(t, expected, got)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDuplicateKey tests slice deduplication via key function
|
||||
func TestDeduplicateKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stringTests := []struct {
|
||||
name string
|
||||
slice []string
|
||||
keyFn func(string) string
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "EmptyStringSlice",
|
||||
slice: []string{},
|
||||
keyFn: func(s string) string { return s },
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "NoStringDuplicates",
|
||||
slice: []string{"foo", "bar", "baz"},
|
||||
keyFn: func(s string) string { return s },
|
||||
expected: []string{"foo", "bar", "baz"},
|
||||
},
|
||||
{
|
||||
name: "StringDuplicates",
|
||||
slice: []string{"foo", "bar", "bar", "bar", "baz", "baz"},
|
||||
keyFn: func(s string) string { return s },
|
||||
expected: []string{"foo", "bar", "baz"},
|
||||
},
|
||||
{
|
||||
name: "StringDuplicatesWeirdKeyFn",
|
||||
slice: []string{"foo", "bar", "bar", "bar", "baz", "baz"},
|
||||
keyFn: func(s string) string { return "huh" },
|
||||
expected: []string{"foo"},
|
||||
},
|
||||
}
|
||||
for _, tt := range stringTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := DeduplicateKey(tt.slice, tt.keyFn)
|
||||
require.Equal(t, tt.expected, res)
|
||||
})
|
||||
}
|
||||
|
||||
type dedupeStruct struct {
|
||||
a string
|
||||
b int
|
||||
c bool
|
||||
}
|
||||
dedupeStructKeyFn := func(d dedupeStruct) string { return fmt.Sprintf("%s:%d:%v", d.a, d.b, d.c) }
|
||||
structTests := []struct {
|
||||
name string
|
||||
slice []dedupeStruct
|
||||
keyFn func(d dedupeStruct) string
|
||||
expected []dedupeStruct
|
||||
}{
|
||||
{
|
||||
name: "EmptySlice",
|
||||
slice: []dedupeStruct{},
|
||||
keyFn: dedupeStructKeyFn,
|
||||
expected: []dedupeStruct{},
|
||||
},
|
||||
{
|
||||
name: "NoStructDuplicates",
|
||||
slice: []dedupeStruct{
|
||||
{a: "foo", b: 1, c: true},
|
||||
{a: "foo", b: 1, c: false},
|
||||
{a: "foo", b: 2, c: true},
|
||||
{a: "bar", b: 1, c: true},
|
||||
{a: "bar", b: 1, c: false},
|
||||
{a: "bar", b: 2, c: true},
|
||||
},
|
||||
keyFn: dedupeStructKeyFn,
|
||||
expected: []dedupeStruct{
|
||||
{a: "foo", b: 1, c: true},
|
||||
{a: "foo", b: 1, c: false},
|
||||
{a: "foo", b: 2, c: true},
|
||||
{a: "bar", b: 1, c: true},
|
||||
{a: "bar", b: 1, c: false},
|
||||
{a: "bar", b: 2, c: true},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range structTests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := DeduplicateKey(tt.slice, tt.keyFn)
|
||||
require.Equal(t, tt.expected, res)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user