Skip to content

Commit 2ee509f

Browse files
DavidS-ovmactions-user
authored andcommitted
Create Azure adapter: AuthorizationRoleDefinition (#4541)
<!-- CURSOR_AGENT_PR_BODY_BEGIN --> ## Summary This PR adds a new Azure adapter for Role Definitions (`AuthorizationRoleDefinition`), enabling discovery of Azure RBAC role definitions at the subscription scope. ## Changes - **Client interface**: `sources/azure/clients/role-definitions-client.go` - Interface wrapping the Azure SDK `RoleDefinitionsClient` - **Mock**: `sources/azure/shared/mocks/mock_role_definitions_client.go` - Generated mock for unit testing - **Adapter**: `sources/azure/manual/authorization-role-definition.go` - Subscription-scoped `ListableWrapper` implementation - **Registration**: Updated `sources/azure/manual/adapters.go` to register the adapter - **Unit tests**: `sources/azure/manual/authorization-role-definition_test.go` - Comprehensive test coverage - **Integration test**: `sources/azure/integration-tests/authorization-role-definition_test.go` - Tests against live Azure APIs - **New item types**: Added `ResourcesSubscription` and `ResourcesResourceGroup` for linked item queries - **New helper**: Added `ExtractResourceGroupFromResourceID` utility function ## Implementation Details - **Wrapper type**: `ListableWrapper` with `SubscriptionBase` (subscription-scoped resource) - **Item type**: `AuthorizationRoleDefinition` (already defined in `item-types.go`) - **API**: Uses `client.Get(ctx, scope, roleDefinitionID, options)` and `client.NewListPager(scope, options)` where `scope` is the Azure resource scope string (e.g., `/subscriptions/{sub}`) - **Unique attribute**: `name` (the role definition GUID) - **Linked items**: Links to `ResourcesSubscription` and `ResourcesResourceGroup` from the `AssignableScopes` field ## Self-Review Checklist - [x] **IAMPermissions**: Present, references `Microsoft.Authorization/roleDefinitions/read` - [x] **PredefinedRole**: Present, uses `Reader` - [x] **LinkedItemQueries**: Links to subscriptions and resource groups from `AssignableScopes` field - [x] **PotentialLinks**: Includes `ResourcesSubscription` and `ResourcesResourceGroup` - [x] **Unit tests**: All passing (Get, List, ListStream, error handling, interface compliance, StaticTests) - [x] **Integration test**: All sub-tests passing (Setup, Run, Teardown) against live Azure APIs - verified Reader, Contributor, Owner built-in roles All checklist items passed. Ready for review. ## Related - Linear issue: ENG-3557 - Reference adapter: `authorization-role-assignment.go` (same `armauthorization` package) <!-- CURSOR_AGENT_PR_BODY_END --> Linear Issue: [ENG-3557](https://linear.app/overmind/issue/ENG-3557/create-azure-adapter-authorizationroledefinition) <div><a href="https://cursor.com/agents/bc-fb889427-749c-4076-837f-c450b24babe0"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-web-light.png"><img alt="Open in Web" width="114" height="28" src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a>&nbsp;<a href="https://cursor.com/background-agent?bcId=bc-fb889427-749c-4076-837f-c450b24babe0"><picture><source media="(prefers-color-scheme: dark)" srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source media="(prefers-color-scheme: light)" srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img alt="Open in Cursor" width="131" height="28" src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a>&nbsp;</div> GitOrigin-RevId: 9f62e2c0d39496048e9fb6325869ba03107320b6
1 parent 05cd3e2 commit 2ee509f

9 files changed

Lines changed: 1111 additions & 0 deletions
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package clients
2+
3+
import (
4+
"context"
5+
6+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3"
7+
)
8+
9+
//go:generate mockgen -destination=../shared/mocks/mock_role_definitions_client.go -package=mocks -source=role-definitions-client.go
10+
11+
// RoleDefinitionsPager is a type alias for the generic Pager interface with role definition response type.
12+
type RoleDefinitionsPager = Pager[armauthorization.RoleDefinitionsClientListResponse]
13+
14+
// RoleDefinitionsClient is an interface for interacting with Azure role definitions
15+
type RoleDefinitionsClient interface {
16+
NewListPager(scope string, options *armauthorization.RoleDefinitionsClientListOptions) RoleDefinitionsPager
17+
Get(ctx context.Context, scope string, roleDefinitionID string, options *armauthorization.RoleDefinitionsClientGetOptions) (armauthorization.RoleDefinitionsClientGetResponse, error)
18+
}
19+
20+
type roleDefinitionsClient struct {
21+
client *armauthorization.RoleDefinitionsClient
22+
}
23+
24+
func (c *roleDefinitionsClient) NewListPager(scope string, options *armauthorization.RoleDefinitionsClientListOptions) RoleDefinitionsPager {
25+
return c.client.NewListPager(scope, options)
26+
}
27+
28+
func (c *roleDefinitionsClient) Get(ctx context.Context, scope string, roleDefinitionID string, options *armauthorization.RoleDefinitionsClientGetOptions) (armauthorization.RoleDefinitionsClientGetResponse, error) {
29+
return c.client.Get(ctx, scope, roleDefinitionID, options)
30+
}
31+
32+
// NewRoleDefinitionsClient creates a new RoleDefinitionsClient from the Azure SDK client
33+
func NewRoleDefinitionsClient(client *armauthorization.RoleDefinitionsClient) RoleDefinitionsClient {
34+
return &roleDefinitionsClient{client: client}
35+
}
Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
package integrationtests
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"testing"
7+
8+
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3"
9+
log "github.com/sirupsen/logrus"
10+
11+
"github.com/overmindtech/cli/go/discovery"
12+
"github.com/overmindtech/cli/go/sdp-go"
13+
"github.com/overmindtech/cli/go/sdpcache"
14+
"github.com/overmindtech/cli/sources"
15+
"github.com/overmindtech/cli/sources/azure/clients"
16+
"github.com/overmindtech/cli/sources/azure/manual"
17+
azureshared "github.com/overmindtech/cli/sources/azure/shared"
18+
)
19+
20+
func TestAuthorizationRoleDefinitionIntegration(t *testing.T) {
21+
subscriptionID := os.Getenv("AZURE_SUBSCRIPTION_ID")
22+
if subscriptionID == "" {
23+
t.Skip("AZURE_SUBSCRIPTION_ID environment variable not set")
24+
}
25+
26+
cred, err := azureshared.NewAzureCredential(t.Context())
27+
if err != nil {
28+
t.Fatalf("Failed to create Azure credential: %v", err)
29+
}
30+
31+
roleDefinitionsClient, err := armauthorization.NewRoleDefinitionsClient(cred, nil)
32+
if err != nil {
33+
t.Fatalf("Failed to create Role Definitions client: %v", err)
34+
}
35+
36+
// Use a built-in role definition ID that always exists: "Reader"
37+
// The Reader role ID is the same across all Azure subscriptions
38+
readerRoleDefinitionID := "acdd72a7-3385-48ef-bd42-f606fba81ae7"
39+
40+
t.Run("Setup", func(t *testing.T) {
41+
// No setup required for role definitions - they are built-in Azure resources
42+
log.Printf("Using built-in Reader role definition ID: %s", readerRoleDefinitionID)
43+
})
44+
45+
t.Run("Run", func(t *testing.T) {
46+
t.Run("GetRoleDefinition", func(t *testing.T) {
47+
ctx := t.Context()
48+
49+
log.Printf("Retrieving role definition %s", readerRoleDefinitionID)
50+
51+
wrapper := manual.NewAuthorizationRoleDefinition(
52+
clients.NewRoleDefinitionsClient(roleDefinitionsClient),
53+
subscriptionID,
54+
)
55+
scope := wrapper.Scopes()[0]
56+
57+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
58+
sdpItem, qErr := adapter.Get(ctx, scope, readerRoleDefinitionID, true)
59+
if qErr != nil {
60+
t.Fatalf("Expected no error, got: %v", qErr)
61+
}
62+
63+
if sdpItem == nil {
64+
t.Fatalf("Expected sdpItem to be non-nil")
65+
}
66+
67+
if sdpItem.GetType() != azureshared.AuthorizationRoleDefinition.String() {
68+
t.Errorf("Expected type %s, got %s", azureshared.AuthorizationRoleDefinition.String(), sdpItem.GetType())
69+
}
70+
71+
uniqueAttrKey := sdpItem.GetUniqueAttribute()
72+
if uniqueAttrKey != "name" {
73+
t.Errorf("Expected unique attribute 'name', got %s", uniqueAttrKey)
74+
}
75+
76+
uniqueAttrValue, err := sdpItem.GetAttributes().Get(uniqueAttrKey)
77+
if err != nil {
78+
t.Fatalf("Failed to get unique attribute: %v", err)
79+
}
80+
81+
if uniqueAttrValue != readerRoleDefinitionID {
82+
t.Errorf("Expected unique attribute value %s, got %s", readerRoleDefinitionID, uniqueAttrValue)
83+
}
84+
85+
if sdpItem.GetScope() != subscriptionID {
86+
t.Errorf("Expected scope %s, got %s", subscriptionID, sdpItem.GetScope())
87+
}
88+
89+
if err := sdpItem.Validate(); err != nil {
90+
t.Fatalf("Item validation failed: %v", err)
91+
}
92+
93+
log.Printf("Successfully retrieved role definition %s", readerRoleDefinitionID)
94+
})
95+
96+
t.Run("ListRoleDefinitions", func(t *testing.T) {
97+
ctx := t.Context()
98+
99+
log.Printf("Listing role definitions in subscription %s", subscriptionID)
100+
101+
wrapper := manual.NewAuthorizationRoleDefinition(
102+
clients.NewRoleDefinitionsClient(roleDefinitionsClient),
103+
subscriptionID,
104+
)
105+
scope := wrapper.Scopes()[0]
106+
107+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
108+
109+
listable, ok := adapter.(discovery.ListableAdapter)
110+
if !ok {
111+
t.Fatalf("Adapter does not support List operation")
112+
}
113+
114+
sdpItems, err := listable.List(ctx, scope, true)
115+
if err != nil {
116+
t.Fatalf("Failed to list role definitions: %v", err)
117+
}
118+
119+
// Azure has many built-in role definitions, expect at least a few
120+
if len(sdpItems) < 5 {
121+
t.Fatalf("Expected at least 5 role definitions, got %d", len(sdpItems))
122+
}
123+
124+
var found bool
125+
for _, item := range sdpItems {
126+
uniqueAttrKey := item.GetUniqueAttribute()
127+
if v, err := item.GetAttributes().Get(uniqueAttrKey); err == nil {
128+
if v == readerRoleDefinitionID {
129+
found = true
130+
break
131+
}
132+
}
133+
}
134+
135+
if !found {
136+
t.Fatalf("Expected to find Reader role definition %s in the list results", readerRoleDefinitionID)
137+
}
138+
139+
log.Printf("Found %d role definitions in list results", len(sdpItems))
140+
})
141+
142+
t.Run("VerifyItemAttributes", func(t *testing.T) {
143+
ctx := t.Context()
144+
145+
log.Printf("Verifying item attributes for role definition %s", readerRoleDefinitionID)
146+
147+
wrapper := manual.NewAuthorizationRoleDefinition(
148+
clients.NewRoleDefinitionsClient(roleDefinitionsClient),
149+
subscriptionID,
150+
)
151+
scope := wrapper.Scopes()[0]
152+
153+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
154+
sdpItem, qErr := adapter.Get(ctx, scope, readerRoleDefinitionID, true)
155+
if qErr != nil {
156+
t.Fatalf("Expected no error, got: %v", qErr)
157+
}
158+
159+
// Verify item type
160+
if sdpItem.GetType() != azureshared.AuthorizationRoleDefinition.String() {
161+
t.Errorf("Expected item type %s, got %s", azureshared.AuthorizationRoleDefinition.String(), sdpItem.GetType())
162+
}
163+
164+
// Verify scope
165+
if sdpItem.GetScope() != subscriptionID {
166+
t.Errorf("Expected scope %s, got %s", subscriptionID, sdpItem.GetScope())
167+
}
168+
169+
// Verify unique attribute
170+
if sdpItem.GetUniqueAttribute() != "name" {
171+
t.Errorf("Expected unique attribute 'name', got %s", sdpItem.GetUniqueAttribute())
172+
}
173+
174+
// Verify item validation
175+
if err := sdpItem.Validate(); err != nil {
176+
t.Fatalf("Item validation failed: %v", err)
177+
}
178+
179+
// Verify role name is Reader
180+
roleName, err := sdpItem.GetAttributes().Get("properties.roleName")
181+
if err != nil {
182+
t.Logf("Warning: Could not get roleName attribute: %v", err)
183+
} else if roleName != "Reader" {
184+
t.Errorf("Expected role name 'Reader', got %s", roleName)
185+
}
186+
187+
log.Printf("Verified item attributes for role definition %s", readerRoleDefinitionID)
188+
})
189+
190+
t.Run("VerifyLinkedItems", func(t *testing.T) {
191+
ctx := t.Context()
192+
193+
log.Printf("Verifying linked items for role definition %s", readerRoleDefinitionID)
194+
195+
wrapper := manual.NewAuthorizationRoleDefinition(
196+
clients.NewRoleDefinitionsClient(roleDefinitionsClient),
197+
subscriptionID,
198+
)
199+
scope := wrapper.Scopes()[0]
200+
201+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
202+
sdpItem, qErr := adapter.Get(ctx, scope, readerRoleDefinitionID, true)
203+
if qErr != nil {
204+
t.Fatalf("Expected no error, got: %v", qErr)
205+
}
206+
207+
// Role definitions link to AssignableScopes (subscriptions and resource groups)
208+
// The built-in Reader role has "/" as its assignable scope, which may not produce links
209+
// Custom roles would have specific subscription/resource group scopes
210+
linkedQueries := sdpItem.GetLinkedItemQueries()
211+
212+
// Verify each linked query has proper attributes
213+
for _, linkedQuery := range linkedQueries {
214+
query := linkedQuery.GetQuery()
215+
if query.GetType() == "" {
216+
t.Error("Linked item query has empty Type")
217+
}
218+
if query.GetMethod() != sdp.QueryMethod_GET && query.GetMethod() != sdp.QueryMethod_SEARCH {
219+
t.Errorf("Linked item query has invalid Method: %v", query.GetMethod())
220+
}
221+
if query.GetQuery() == "" {
222+
t.Error("Linked item query has empty Query")
223+
}
224+
if query.GetScope() == "" {
225+
t.Error("Linked item query has empty Scope")
226+
}
227+
228+
// Verify linked types are either subscription or resource group
229+
validTypes := map[string]bool{
230+
azureshared.ResourcesSubscription.String(): true,
231+
azureshared.ResourcesResourceGroup.String(): true,
232+
}
233+
if !validTypes[query.GetType()] {
234+
t.Errorf("Unexpected linked item type: %s", query.GetType())
235+
}
236+
}
237+
238+
log.Printf("Verified linked items for role definition %s (found %d linked queries)", readerRoleDefinitionID, len(linkedQueries))
239+
})
240+
241+
t.Run("VerifyBuiltInRoles", func(t *testing.T) {
242+
ctx := t.Context()
243+
244+
// Verify some well-known built-in role definitions exist
245+
builtInRoles := map[string]string{
246+
"acdd72a7-3385-48ef-bd42-f606fba81ae7": "Reader",
247+
"b24988ac-6180-42a0-ab88-20f7382dd24c": "Contributor",
248+
"8e3af657-a8ff-443c-a75c-2fe8c4bcb635": "Owner",
249+
}
250+
251+
wrapper := manual.NewAuthorizationRoleDefinition(
252+
clients.NewRoleDefinitionsClient(roleDefinitionsClient),
253+
subscriptionID,
254+
)
255+
scope := wrapper.Scopes()[0]
256+
adapter := sources.WrapperToAdapter(wrapper, sdpcache.NewNoOpCache())
257+
258+
for roleID, roleName := range builtInRoles {
259+
t.Run(fmt.Sprintf("Get%sRole", roleName), func(t *testing.T) {
260+
sdpItem, qErr := adapter.Get(ctx, scope, roleID, true)
261+
if qErr != nil {
262+
t.Fatalf("Failed to get %s role definition: %v", roleName, qErr)
263+
}
264+
265+
if sdpItem == nil {
266+
t.Fatalf("Expected %s role definition to be non-nil", roleName)
267+
}
268+
269+
actualRoleName, err := sdpItem.GetAttributes().Get("properties.roleName")
270+
if err != nil {
271+
t.Logf("Warning: Could not get roleName attribute for %s: %v", roleName, err)
272+
} else if actualRoleName != roleName {
273+
t.Errorf("Expected role name '%s', got '%s'", roleName, actualRoleName)
274+
}
275+
276+
log.Printf("Successfully verified built-in role: %s (ID: %s)", roleName, roleID)
277+
})
278+
}
279+
})
280+
})
281+
282+
t.Run("Teardown", func(t *testing.T) {
283+
// No teardown required - role definitions are built-in Azure resources
284+
log.Printf("No teardown required for role definitions (built-in Azure resources)")
285+
})
286+
}

sources/azure/manual/adapters.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,11 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred
433433
return nil, fmt.Errorf("failed to create role assignments client: %w", err)
434434
}
435435

436+
roleDefinitionsClient, err := armauthorization.NewRoleDefinitionsClient(cred, nil)
437+
if err != nil {
438+
return nil, fmt.Errorf("failed to create role definitions client: %w", err)
439+
}
440+
436441
diskEncryptionSetsClient, err := armcompute.NewDiskEncryptionSetsClient(subscriptionID, cred, nil)
437442
if err != nil {
438443
return nil, fmt.Errorf("failed to create disk encryption sets client: %w", err)
@@ -949,6 +954,10 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred
949954
clients.NewSharedGalleryImagesClient(sharedGalleryImagesClient),
950955
subscriptionID,
951956
), cache),
957+
sources.WrapperToAdapter(NewAuthorizationRoleDefinition(
958+
clients.NewRoleDefinitionsClient(roleDefinitionsClient),
959+
subscriptionID,
960+
), cache),
952961
)
953962

954963
log.WithFields(log.Fields{
@@ -1055,6 +1064,7 @@ func Adapters(ctx context.Context, subscriptionID string, regions []string, cred
10551064
sources.WrapperToAdapter(NewElasticSanVolumeGroup(nil, placeholderResourceGroupScopes), noOpCache),
10561065
sources.WrapperToAdapter(NewElasticSanVolume(nil, placeholderResourceGroupScopes), noOpCache),
10571066
sources.WrapperToAdapter(NewComputeSharedGalleryImage(nil, subscriptionID), noOpCache),
1067+
sources.WrapperToAdapter(NewAuthorizationRoleDefinition(nil, subscriptionID), noOpCache),
10581068
sources.WrapperToAdapter(NewNetworkPrivateEndpoint(nil, placeholderResourceGroupScopes), noOpCache),
10591069
sources.WrapperToAdapter(NewNetworkPrivateLinkService(nil, placeholderResourceGroupScopes), noOpCache),
10601070
sources.WrapperToAdapter(NewOperationalInsightsWorkspace(nil, placeholderResourceGroupScopes), noOpCache),

0 commit comments

Comments
 (0)