diff --git a/models/app-fic.go b/models/app-fic.go index 00cc841..a6edb91 100644 --- a/models/app-fic.go +++ b/models/app-fic.go @@ -54,6 +54,17 @@ type AppFICs struct { TenantName string `json:"tenantName"` } +// MarshalJSON uppercases the AppId, TenantId, and TenantName identifiers; each +// FIC entry is marshaled through its own MarshalJSON. Non-mutating. +func (s AppFICs) MarshalJSON() ([]byte, error) { + type Alias AppFICs + a := Alias(s) + a.AppId = strings.ToUpper(a.AppId) + a.TenantId = strings.ToUpper(a.TenantId) + a.TenantName = strings.ToUpper(a.TenantName) + return json.Marshal(a) +} + type FICData struct { Audiences []string `json:"audiences"` ID string `json:"id"` diff --git a/models/app-role-assignments.go b/models/app-role-assignments.go index ea28778..676f88a 100644 --- a/models/app-role-assignments.go +++ b/models/app-role-assignments.go @@ -34,6 +34,7 @@ func (s AppRoleAssignment) MarshalJSON() ([]byte, error) { type Alias AppRoleAssignment a := Alias(s) a.ResourceId = strings.ToUpper(a.ResourceId) + a.AppId = strings.ToUpper(a.AppId) a.TenantId = strings.ToUpper(a.TenantId) // PrincipalId is a uuid.UUID and cannot hold an uppercased string, so emit diff --git a/models/app.go b/models/app.go index e16805a..18aefd8 100644 --- a/models/app.go +++ b/models/app.go @@ -35,6 +35,8 @@ func (s App) MarshalJSON() ([]byte, error) { a := Alias(s) a.Id = strings.ToUpper(a.Id) a.AppId = strings.ToUpper(a.AppId) + a.DisplayName = strings.ToUpper(a.DisplayName) a.TenantId = strings.ToUpper(a.TenantId) + a.TenantName = strings.ToUpper(a.TenantName) return json.Marshal(a) } diff --git a/models/automation-account.go b/models/automation-account.go index c6a1427..7e6b3c2 100644 --- a/models/automation-account.go +++ b/models/automation-account.go @@ -38,6 +38,7 @@ func (s AutomationAccount) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.ResourceGroupName = strings.ToUpper(a.ResourceGroupName) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) return json.Marshal(a) diff --git a/models/azure/descendant-info.go b/models/azure/descendant-info.go index 3d8cd76..5dababc 100644 --- a/models/azure/descendant-info.go +++ b/models/azure/descendant-info.go @@ -71,5 +71,6 @@ func (s DescendantInfo) MarshalJSON() ([]byte, error) { a := Alias(s) a.Id = strings.ToUpper(a.Id) a.Properties.Parent.Id = strings.ToUpper(a.Properties.Parent.Id) + a.Properties.DisplayName = strings.ToUpper(a.Properties.DisplayName) return json.Marshal(a) } diff --git a/models/container-registry.go b/models/container-registry.go index a6316be..c02d9c7 100644 --- a/models/container-registry.go +++ b/models/container-registry.go @@ -38,6 +38,7 @@ func (s ContainerRegistry) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.ResourceGroupName = strings.ToUpper(a.ResourceGroupName) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) return json.Marshal(a) diff --git a/models/device.go b/models/device.go index 0c91799..a22cf48 100644 --- a/models/device.go +++ b/models/device.go @@ -34,6 +34,9 @@ func (s Device) MarshalJSON() ([]byte, error) { type Alias Device a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.DeviceId = strings.ToUpper(a.DeviceId) + a.DisplayName = strings.ToUpper(a.DisplayName) a.TenantId = strings.ToUpper(a.TenantId) + a.TenantName = strings.ToUpper(a.TenantName) return json.Marshal(a) } diff --git a/models/function-app.go b/models/function-app.go index 374dd21..eba26fb 100644 --- a/models/function-app.go +++ b/models/function-app.go @@ -38,6 +38,7 @@ func (s FunctionApp) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.ResourceGroupName = strings.ToUpper(a.ResourceGroupName) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) return json.Marshal(a) diff --git a/models/group.go b/models/group.go index 0e7915e..70d0c21 100644 --- a/models/group.go +++ b/models/group.go @@ -36,5 +36,7 @@ func (s Group) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.TenantId = strings.ToUpper(a.TenantId) a.OnPremisesSecurityIdentifier = strings.ToUpper(a.OnPremisesSecurityIdentifier) + a.DisplayName = strings.ToUpper(a.DisplayName) + a.TenantName = strings.ToUpper(a.TenantName) return json.Marshal(a) } diff --git a/models/key-vault.go b/models/key-vault.go index 79a5491..449d30a 100644 --- a/models/key-vault.go +++ b/models/key-vault.go @@ -38,5 +38,16 @@ func (s KeyVault) MarshalJSON() ([]byte, error) { a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroup = strings.ToUpper(a.ResourceGroup) a.TenantId = strings.ToUpper(a.TenantId) + a.Properties.TenantId = strings.ToUpper(a.Properties.TenantId) + if s.Properties.AccessPolicies != nil { + policies := make([]azure.AccessPolicyEntry, len(s.Properties.AccessPolicies)) + for i, policy := range s.Properties.AccessPolicies { + policy.ObjectId = strings.ToUpper(policy.ObjectId) + policy.ApplicationId = strings.ToUpper(policy.ApplicationId) + policy.TenantId = strings.ToUpper(policy.TenantId) + policies[i] = policy + } + a.Properties.AccessPolicies = policies + } return json.Marshal(a) } diff --git a/models/logic-app.go b/models/logic-app.go index 1b4f419..9f5904c 100644 --- a/models/logic-app.go +++ b/models/logic-app.go @@ -38,6 +38,7 @@ func (s LogicApp) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.ResourceGroupName = strings.ToUpper(a.ResourceGroupName) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) return json.Marshal(a) diff --git a/models/managed-cluster.go b/models/managed-cluster.go index c742750..dbc0954 100644 --- a/models/managed-cluster.go +++ b/models/managed-cluster.go @@ -38,5 +38,7 @@ func (s ManagedCluster) MarshalJSON() ([]byte, error) { a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) a.TenantId = strings.ToUpper(a.TenantId) + a.Properties.NodeResourceGroup = strings.ToUpper(a.Properties.NodeResourceGroup) + a.Identity = UpperManagedIdentity(a.Identity) return json.Marshal(a) } diff --git a/models/marshal_test.go b/models/marshal_test.go index d2afe30..ba0489a 100644 --- a/models/marshal_test.go +++ b/models/marshal_test.go @@ -20,51 +20,62 @@ func marshalToMap(t *testing.T, v any) map[string]any { } func TestUserMarshalJSONUppercasesIdentifiers(t *testing.T) { - user := models.User{TenantId: "tenant-abc"} + user := models.User{TenantId: "tenant-abc", TenantName: "contoso.onmicrosoft.com"} user.Id = "user-def" user.OnPremisesSecurityIdentifier = "s-1-5-21-abc-def" + user.DisplayName = "Alice Example" out := marshalToMap(t, user) require.Equal(t, "USER-DEF", out["id"]) require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "CONTOSO.ONMICROSOFT.COM", out["tenantName"]) // On-prem SID is the AZ->AD hybrid join key and must be uppercased. require.Equal(t, "S-1-5-21-ABC-DEF", out["onPremisesSecurityIdentifier"]) + require.Equal(t, "ALICE EXAMPLE", out["displayName"]) // Source is unchanged. require.Equal(t, "user-def", user.Id) require.Equal(t, "tenant-abc", user.TenantId) require.Equal(t, "s-1-5-21-abc-def", user.OnPremisesSecurityIdentifier) + require.Equal(t, "Alice Example", user.DisplayName) } func TestGroupMarshalJSONUppercasesOnPremSID(t *testing.T) { group := models.Group{TenantId: "tenant-abc"} group.Id = "group-def" group.OnPremisesSecurityIdentifier = "s-1-5-21-ghi-jkl" + group.DisplayName = "Engineering" out := marshalToMap(t, group) require.Equal(t, "GROUP-DEF", out["id"]) require.Equal(t, "TENANT-ABC", out["tenantId"]) require.Equal(t, "S-1-5-21-GHI-JKL", out["onPremisesSecurityIdentifier"]) + require.Equal(t, "ENGINEERING", out["displayName"]) // Source is unchanged. require.Equal(t, "group-def", group.Id) require.Equal(t, "s-1-5-21-ghi-jkl", group.OnPremisesSecurityIdentifier) + require.Equal(t, "Engineering", group.DisplayName) } func TestAppMarshalJSONUppercasesIdentifiers(t *testing.T) { - app := models.App{TenantId: "tenant-abc"} + app := models.App{TenantId: "tenant-abc", TenantName: "contoso.onmicrosoft.com"} app.Id = "app-def" app.AppId = "appid-ghi" + app.DisplayName = "My App" out := marshalToMap(t, app) require.Equal(t, "APP-DEF", out["id"]) require.Equal(t, "APPID-GHI", out["appId"]) require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "CONTOSO.ONMICROSOFT.COM", out["tenantName"]) + require.Equal(t, "MY APP", out["displayName"]) // Source is unchanged. require.Equal(t, "app-def", app.Id) require.Equal(t, "appid-ghi", app.AppId) require.Equal(t, "tenant-abc", app.TenantId) + require.Equal(t, "My App", app.DisplayName) } func TestVirtualMachineMarshalJSONUppercasesIdentityNonMutating(t *testing.T) { @@ -207,6 +218,10 @@ func TestAppFICMarshalJSONUppercasesAppIdAndFicId(t *testing.T) { out := marshalToMap(t, fics) + // Top-level wrapper identifiers are uppercased, including tenantName. + require.Equal(t, "APP-1", out["appId"]) + require.Equal(t, "TENANT-1", out["tenantId"]) + require.Equal(t, "SPECTEROPS DEVELOPMENT", out["tenantName"]) entry := out["fics"].([]any)[0].(map[string]any) // appId is the AZApp edge endpoint; fic.id is the FIC node ObjectID / source. require.Equal(t, "APP-1", entry["appId"]) @@ -261,6 +276,7 @@ func TestAppRoleAssignmentMarshalJSONUppercasesUUIDFields(t *testing.T) { require.Equal(t, "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE", out["principalId"]) require.Equal(t, "RESOURCE-1", out["resourceId"]) + require.Equal(t, "APP-1", out["appId"]) require.Equal(t, "TENANT-1", out["tenantId"]) // AppRoleId is used for lowercase matching in ingest and must remain untouched. require.Equal(t, "11111111-2222-3333-4444-555555555555", out["appRoleId"]) @@ -414,6 +430,7 @@ func TestDescendantInfoMarshalJSONUppercasesIds(t *testing.T) { Id: "/providers/managementgroups/mg-child", } descendant.Properties.Parent.Id = "/providers/managementgroups/mg-parent" + descendant.Properties.DisplayName = "Child MG" out := marshalToMap(t, descendant) @@ -421,4 +438,299 @@ func TestDescendantInfoMarshalJSONUppercasesIds(t *testing.T) { props := out["properties"].(map[string]any) parent := props["parent"].(map[string]any) require.Equal(t, "/PROVIDERS/MANAGEMENTGROUPS/MG-PARENT", parent["id"]) + require.Equal(t, "CHILD MG", props["display_name"]) +} + +func TestServicePrincipalMarshalJSONUppercasesIdentifiers(t *testing.T) { + sp := models.ServicePrincipal{TenantId: "tenant-abc", TenantName: "contoso.onmicrosoft.com"} + sp.Id = "sp-def" + sp.AppId = "appid-ghi" + sp.AppOwnerOrganizationId = "owner-org-1" + sp.DisplayName = "My SP" + + out := marshalToMap(t, sp) + + require.Equal(t, "SP-DEF", out["id"]) + require.Equal(t, "APPID-GHI", out["appId"]) + require.Equal(t, "OWNER-ORG-1", out["appOwnerOrganizationId"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "CONTOSO.ONMICROSOFT.COM", out["tenantName"]) + require.Equal(t, "MY SP", out["displayName"]) + // Source is unchanged. + require.Equal(t, "sp-def", sp.Id) + require.Equal(t, "My SP", sp.DisplayName) +} + +func TestSubscriptionMarshalJSONUppercasesDisplayName(t *testing.T) { + sub := models.Subscription{TenantId: "tenant-abc"} + sub.Id = "/subscriptions/sub-1" + sub.SubscriptionId = "sub-guid-1" + sub.DisplayName = "Prod Subscription" + + out := marshalToMap(t, sub) + + require.Equal(t, "/SUBSCRIPTIONS/SUB-1", out["id"]) + require.Equal(t, "SUB-GUID-1", out["subscriptionId"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "PROD SUBSCRIPTION", out["displayName"]) + require.Equal(t, "Prod Subscription", sub.DisplayName) +} + +func TestTenantMarshalJSONUppercasesDisplayName(t *testing.T) { + tenant := models.Tenant{} + tenant.Id = "/tenants/tenant-abc" + tenant.TenantId = "tenant-abc" + tenant.DisplayName = "Contoso" + + out := marshalToMap(t, tenant) + + require.Equal(t, "/TENANTS/TENANT-ABC", out["id"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "CONTOSO", out["displayName"]) + require.Equal(t, "Contoso", tenant.DisplayName) +} + +func TestRoleMarshalJSONUppercasesDisplayName(t *testing.T) { + role := models.Role{TenantId: "tenant-abc", TenantName: "contoso.onmicrosoft.com"} + role.Id = "role-def" + role.DisplayName = "Global Administrator" + + out := marshalToMap(t, role) + + require.Equal(t, "ROLE-DEF", out["id"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "CONTOSO.ONMICROSOFT.COM", out["tenantName"]) + require.Equal(t, "GLOBAL ADMINISTRATOR", out["displayName"]) + require.Equal(t, "Global Administrator", role.DisplayName) +} + +func TestDeviceMarshalJSONUppercasesDisplayName(t *testing.T) { + device := models.Device{TenantId: "tenant-abc", TenantName: "contoso.onmicrosoft.com"} + device.Id = "device-def" + device.DeviceId = "device-guid-1" + device.DisplayName = "Laptop-01" + + out := marshalToMap(t, device) + + require.Equal(t, "DEVICE-DEF", out["id"]) + require.Equal(t, "DEVICE-GUID-1", out["deviceId"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + require.Equal(t, "CONTOSO.ONMICROSOFT.COM", out["tenantName"]) + require.Equal(t, "LAPTOP-01", out["displayName"]) + require.Equal(t, "Laptop-01", device.DisplayName) +} + +func TestManagementGroupMarshalJSONUppercasesDisplayName(t *testing.T) { + mg := models.ManagementGroup{TenantId: "tenant-abc"} + mg.Id = "/providers/managementgroups/mg-1" + mg.Properties.DisplayName = "Root MG" + mg.Properties.TenantId = "tenant-abc" + + out := marshalToMap(t, mg) + + require.Equal(t, "/PROVIDERS/MANAGEMENTGROUPS/MG-1", out["id"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + props := out["properties"].(map[string]any) + require.Equal(t, "ROOT MG", props["displayName"]) + require.Equal(t, "TENANT-ABC", props["tenantId"]) + require.Equal(t, "Root MG", mg.Properties.DisplayName) +} + +func TestKeyVaultMarshalJSONUppercasesPropertiesTenantId(t *testing.T) { + kv := models.KeyVault{ + SubscriptionId: "sub-1", + ResourceGroup: "rg-1", + TenantId: "tenant-abc", + } + kv.Id = "/subscriptions/sub-1/resourcegroups/rg-1/providers/kv-1" + kv.Properties.TenantId = "tenant-abc" + + out := marshalToMap(t, kv) + + require.Equal(t, "TENANT-ABC", out["tenantId"]) + props := out["properties"].(map[string]any) + require.Equal(t, "TENANT-ABC", props["tenantId"]) + require.Equal(t, "tenant-abc", kv.Properties.TenantId) +} + +func TestUpperManagedIdentityUppercasesTenantId(t *testing.T) { + vm := models.VirtualMachine{ + SubscriptionId: "sub-1", + ResourceGroupId: "/subscriptions/sub-1/resourcegroups/rg-1", + TenantId: "tenant-1", + } + vm.Id = "/subscriptions/sub-1/resourcegroups/rg-1/providers/vm-1" + vm.Identity.PrincipalId = "principal-sys" + vm.Identity.TenantId = "tenant-1" + + out := marshalToMap(t, vm) + + identity := out["identity"].(map[string]any) + require.Equal(t, "PRINCIPAL-SYS", identity["principalId"]) + require.Equal(t, "TENANT-1", identity["tenantId"]) + // Source is unchanged. + require.Equal(t, "tenant-1", vm.Identity.TenantId) +} + +func TestManagedClusterMarshalJSONUppercasesNodeResourceGroupAndIdentity(t *testing.T) { + mc := models.ManagedCluster{ + SubscriptionId: "sub-1", + ResourceGroupId: "/subscriptions/sub-1/resourcegroups/rg-1", + TenantId: "tenant-1", + } + mc.Id = "/subscriptions/sub-1/resourcegroups/rg-1/providers/mc-1" + mc.Properties.NodeResourceGroup = "mc_rg-1_aks" + mc.Identity.PrincipalId = "principal-sys" + + out := marshalToMap(t, mc) + + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1/PROVIDERS/MC-1", out["id"]) + require.Equal(t, "TENANT-1", out["tenantId"]) + props := out["properties"].(map[string]any) + require.Equal(t, "MC_RG-1_AKS", props["nodeResourceGroup"]) + identity := out["identity"].(map[string]any) + require.Equal(t, "PRINCIPAL-SYS", identity["principalId"]) + // Source is unchanged. + require.Equal(t, "mc_rg-1_aks", mc.Properties.NodeResourceGroup) + require.Equal(t, "principal-sys", mc.Identity.PrincipalId) +} + +func TestStorageAccountMarshalJSONUppercasesIdentifiersAndIdentity(t *testing.T) { + sa := models.StorageAccount{ + SubscriptionId: "sub-1", + ResourceGroupId: "/subscriptions/sub-1/resourcegroups/rg-1", + ResourceGroupName: "rg-1", + TenantId: "tenant-1", + } + sa.Id = "/subscriptions/sub-1/resourcegroups/rg-1/providers/sa-1" + sa.Identity.PrincipalId = "principal-sys" + + out := marshalToMap(t, sa) + + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1/PROVIDERS/SA-1", out["id"]) + require.Equal(t, "SUB-1", out["subscriptionId"]) + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1", out["resourceGroupId"]) + require.Equal(t, "RG-1", out["resourceGroupName"]) + require.Equal(t, "TENANT-1", out["tenantId"]) + identity := out["identity"].(map[string]any) + require.Equal(t, "PRINCIPAL-SYS", identity["principalId"]) + require.Equal(t, "principal-sys", sa.Identity.PrincipalId) +} + +func TestStorageContainerMarshalJSONUppercasesIdentifiers(t *testing.T) { + sc := models.StorageContainer{ + SubscriptionId: "sub-1", + ResourceGroupId: "/subscriptions/sub-1/resourcegroups/rg-1", + ResourceGroupName: "rg-1", + StorageAccountId: "/subscriptions/sub-1/resourcegroups/rg-1/providers/sa-1", + TenantId: "tenant-1", + } + sc.Id = "/subscriptions/sub-1/resourcegroups/rg-1/providers/sa-1/blobservices/default/containers/c-1" + + out := marshalToMap(t, sc) + + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1/PROVIDERS/SA-1/BLOBSERVICES/DEFAULT/CONTAINERS/C-1", out["id"]) + require.Equal(t, "SUB-1", out["subscriptionId"]) + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1", out["resourceGroupId"]) + require.Equal(t, "RG-1", out["resourceGroupName"]) + require.Equal(t, "/SUBSCRIPTIONS/SUB-1/RESOURCEGROUPS/RG-1/PROVIDERS/SA-1", out["storageAccountId"]) + require.Equal(t, "TENANT-1", out["tenantId"]) + // Source is unchanged. + require.Equal(t, "sub-1", sc.SubscriptionId) +} + +func TestRoleAssignmentsMarshalJSONUppercasesInnerEndpoints(t *testing.T) { + ra := models.RoleAssignments{ + RoleDefinitionId: "role-def-1", + TenantId: "tenant-abc", + RoleAssignments: []azure.UnifiedRoleAssignment{ + { + RoleDefinitionId: "role-def-1", + PrincipalId: "principal-1", + DirectoryScopeId: "/administrativeUnits/au-1", + }, + }, + } + // The assignment id is a case-sensitive base64url Graph identifier. + ra.RoleAssignments[0].Id = "lAPPGGoDgkmB_Xyz-123" + + out := marshalToMap(t, ra) + + require.Equal(t, "ROLE-DEF-1", out["roleDefinitionId"]) + require.Equal(t, "TENANT-ABC", out["tenantId"]) + entry := out["roleAssignments"].([]any)[0].(map[string]any) + // The per-assignment id is base64url and must be preserved as-is. + require.Equal(t, "lAPPGGoDgkmB_Xyz-123", entry["id"]) + require.Equal(t, "ROLE-DEF-1", entry["roleDefinitionId"]) + require.Equal(t, "PRINCIPAL-1", entry["principalId"]) + require.Equal(t, "/ADMINISTRATIVEUNITS/AU-1", entry["directoryScopeId"]) + // Source is unchanged. + require.Equal(t, "role-def-1", ra.RoleAssignments[0].RoleDefinitionId) + require.Equal(t, "principal-1", ra.RoleAssignments[0].PrincipalId) +} + +func TestKeyVaultMarshalJSONUppercasesAccessPolicies(t *testing.T) { + kv := models.KeyVault{ + SubscriptionId: "sub-1", + ResourceGroup: "rg-1", + TenantId: "tenant-abc", + } + kv.Id = "/subscriptions/sub-1/resourcegroups/rg-1/providers/kv-1" + kv.Properties.AccessPolicies = []azure.AccessPolicyEntry{ + { + ObjectId: "object-1", + ApplicationId: "app-1", + TenantId: "tenant-abc", + }, + } + + out := marshalToMap(t, kv) + + props := out["properties"].(map[string]any) + policy := props["accessPolicies"].([]any)[0].(map[string]any) + require.Equal(t, "OBJECT-1", policy["objectId"]) + require.Equal(t, "APP-1", policy["applicationId"]) + require.Equal(t, "TENANT-ABC", policy["tenantId"]) + // Source is unchanged. + require.Equal(t, "object-1", kv.Properties.AccessPolicies[0].ObjectId) + require.Equal(t, "app-1", kv.Properties.AccessPolicies[0].ApplicationId) +} + +func TestRoleManagementPolicyAssignmentMarshalJSONUppercasesApproverIds(t *testing.T) { + rule := `{ + "@odata.type": "#microsoft.graph.unifiedRoleManagementPolicyApprovalRule", + "setting": { + "approvalStages": [ + { + "primaryApprovers": [ + {"@odata.type": "#microsoft.graph.groupMembers", "groupId": "group-1"}, + {"@odata.type": "#microsoft.graph.singleUser", "userId": "user-1"} + ] + } + ] + } + }` + rmpa := models.RoleManagementPolicyAssignment{ + Id: "policy-assignment-1", + RoleDefinitionId: "role-def-1", + TenantId: "tenant-abc", + EndUserAssignmentGroupApprovers: []string{"group-1"}, + } + rmpa.Policy.Rules = []json.RawMessage{json.RawMessage(rule)} + + out := marshalToMap(t, rmpa) + + require.Equal(t, "POLICY-ASSIGNMENT-1", out["id"]) + require.Equal(t, "ROLE-DEF-1", out["roleDefinitionId"]) + require.Equal(t, "GROUP-1", out["endUserAssignmentGroupApprovers"].([]any)[0]) + + // Approver ids nested in the raw policy rules are uppercased. + rules := out["policy"].(map[string]any)["rules"].([]any) + setting := rules[0].(map[string]any)["setting"].(map[string]any) + stage := setting["approvalStages"].([]any)[0].(map[string]any) + approvers := stage["primaryApprovers"].([]any) + require.Equal(t, "GROUP-1", approvers[0].(map[string]any)["groupId"]) + require.Equal(t, "USER-1", approvers[1].(map[string]any)["userId"]) + // Source is unchanged. + require.Contains(t, string(rmpa.Policy.Rules[0]), "group-1") } diff --git a/models/mgmt-group.go b/models/mgmt-group.go index 640b336..a2a2c2a 100644 --- a/models/mgmt-group.go +++ b/models/mgmt-group.go @@ -33,6 +33,8 @@ func (s ManagementGroup) MarshalJSON() ([]byte, error) { type Alias ManagementGroup a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.Properties.DisplayName = strings.ToUpper(a.Properties.DisplayName) + a.Properties.TenantId = strings.ToUpper(a.Properties.TenantId) a.TenantId = strings.ToUpper(a.TenantId) return json.Marshal(a) } diff --git a/models/role-assignments.go b/models/role-assignments.go index 2178a37..e9232db 100644 --- a/models/role-assignments.go +++ b/models/role-assignments.go @@ -39,6 +39,7 @@ func (s RoleAssignments) MarshalJSON() ([]byte, error) { if s.RoleAssignments != nil { assignments := make([]azure.UnifiedRoleAssignment, len(s.RoleAssignments)) for i, assignment := range s.RoleAssignments { + assignment.RoleDefinitionId = strings.ToUpper(assignment.RoleDefinitionId) assignment.PrincipalId = strings.ToUpper(assignment.PrincipalId) assignment.DirectoryScopeId = strings.ToUpper(assignment.DirectoryScopeId) if len(assignment.Principal) > 0 { diff --git a/models/role-management-policy-assignment.go b/models/role-management-policy-assignment.go index dfc6666..855dbd9 100644 --- a/models/role-management-policy-assignment.go +++ b/models/role-management-policy-assignment.go @@ -47,5 +47,18 @@ func (s RoleManagementPolicyAssignment) MarshalJSON() ([]byte, error) { a.TenantId = strings.ToUpper(a.TenantId) a.EndUserAssignmentUserApprovers = upperStrings(a.EndUserAssignmentUserApprovers) a.EndUserAssignmentGroupApprovers = upperStrings(a.EndUserAssignmentGroupApprovers) + + // Uppercase approver groupId/userId nested in the raw policy rules. + if s.Policy.Rules != nil { + rules := make([]json.RawMessage, len(s.Policy.Rules)) + for i, rule := range s.Policy.Rules { + if upper, err := UpperRawJSONKeys(rule, "groupId", "userId"); err != nil { + return nil, err + } else { + rules[i] = upper + } + } + a.Policy.Rules = rules + } return json.Marshal(a) } diff --git a/models/role.go b/models/role.go index 7039423..b429d5a 100644 --- a/models/role.go +++ b/models/role.go @@ -34,6 +34,8 @@ func (s Role) MarshalJSON() ([]byte, error) { type Alias Role a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.DisplayName = strings.ToUpper(a.DisplayName) a.TenantId = strings.ToUpper(a.TenantId) + a.TenantName = strings.ToUpper(a.TenantName) return json.Marshal(a) } diff --git a/models/service-principal.go b/models/service-principal.go index ac78884..9c056ba 100644 --- a/models/service-principal.go +++ b/models/service-principal.go @@ -36,6 +36,8 @@ func (s ServicePrincipal) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.AppId = strings.ToUpper(a.AppId) a.AppOwnerOrganizationId = strings.ToUpper(a.AppOwnerOrganizationId) + a.DisplayName = strings.ToUpper(a.DisplayName) a.TenantId = strings.ToUpper(a.TenantId) + a.TenantName = strings.ToUpper(a.TenantName) return json.Marshal(a) } diff --git a/models/storage-account.go b/models/storage-account.go index ff73499..f289414 100644 --- a/models/storage-account.go +++ b/models/storage-account.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type StorageAccount struct { azure.StorageAccount @@ -26,3 +31,15 @@ type StorageAccount struct { ResourceGroupName string `json:"resourceGroupName"` TenantId string `json:"tenantId"` } + +func (s StorageAccount) MarshalJSON() ([]byte, error) { + type Alias StorageAccount + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.ResourceGroupName = strings.ToUpper(a.ResourceGroupName) + a.TenantId = strings.ToUpper(a.TenantId) + a.Identity = UpperManagedIdentity(a.Identity) + return json.Marshal(a) +} diff --git a/models/storage-container.go b/models/storage-container.go index 807610c..eb1996e 100644 --- a/models/storage-container.go +++ b/models/storage-container.go @@ -17,7 +17,12 @@ package models -import "github.com/bloodhoundad/azurehound/v2/models/azure" +import ( + "encoding/json" + "strings" + + "github.com/bloodhoundad/azurehound/v2/models/azure" +) type StorageContainer struct { azure.StorageContainer @@ -27,3 +32,15 @@ type StorageContainer struct { StorageAccountId string `json:"storageAccountId"` TenantId string `json:"tenantId"` } + +func (s StorageContainer) MarshalJSON() ([]byte, error) { + type Alias StorageContainer + a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.ResourceGroupName = strings.ToUpper(a.ResourceGroupName) + a.StorageAccountId = strings.ToUpper(a.StorageAccountId) + a.TenantId = strings.ToUpper(a.TenantId) + return json.Marshal(a) +} diff --git a/models/subscription.go b/models/subscription.go index 681c936..8d39053 100644 --- a/models/subscription.go +++ b/models/subscription.go @@ -33,6 +33,8 @@ func (s Subscription) MarshalJSON() ([]byte, error) { type Alias Subscription a := Alias(s) a.Id = strings.ToUpper(a.Id) + a.SubscriptionId = strings.ToUpper(a.SubscriptionId) + a.DisplayName = strings.ToUpper(a.DisplayName) a.TenantId = strings.ToUpper(a.TenantId) return json.Marshal(a) } diff --git a/models/tenant.go b/models/tenant.go index bb9daac..c1dc677 100644 --- a/models/tenant.go +++ b/models/tenant.go @@ -32,6 +32,8 @@ type Tenant struct { func (s Tenant) MarshalJSON() ([]byte, error) { type Alias Tenant a := Alias(s) + a.Id = strings.ToUpper(a.Id) + a.DisplayName = strings.ToUpper(a.DisplayName) a.TenantId = strings.ToUpper(a.TenantId) return json.Marshal(a) } diff --git a/models/user.go b/models/user.go index db8ee35..1a61f65 100644 --- a/models/user.go +++ b/models/user.go @@ -36,5 +36,7 @@ func (s User) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.TenantId = strings.ToUpper(a.TenantId) a.OnPremisesSecurityIdentifier = strings.ToUpper(a.OnPremisesSecurityIdentifier) + a.DisplayName = strings.ToUpper(a.DisplayName) + a.TenantName = strings.ToUpper(a.TenantName) return json.Marshal(a) } diff --git a/models/utils.go b/models/utils.go index ebad428..e03f036 100644 --- a/models/utils.go +++ b/models/utils.go @@ -1,6 +1,7 @@ package models import ( + "bytes" "encoding/json" "reflect" "strings" @@ -33,11 +34,12 @@ func UpperRoleAssignment(assignment azure.RoleAssignment) azure.RoleAssignment { } // UpperManagedIdentity returns a copy of the provided ManagedIdentity with the -// system-assigned PrincipalId and each user-assigned identity PrincipalId -// uppercased. The input is not mutated: the UserAssignedIdentities map is -// rebuilt into a fresh map. +// system-assigned PrincipalId, TenantId, and each user-assigned identity +// PrincipalId uppercased. The input is not mutated: the UserAssignedIdentities +// map is rebuilt into a fresh map. func UpperManagedIdentity(identity azure.ManagedIdentity) azure.ManagedIdentity { identity.PrincipalId = strings.ToUpper(identity.PrincipalId) + identity.TenantId = strings.ToUpper(identity.TenantId) if identity.UserAssignedIdentities != nil { uais := make(map[string]azure.UserAssignedIdentity, len(identity.UserAssignedIdentities)) for key, uai := range identity.UserAssignedIdentities { @@ -78,6 +80,53 @@ func OmitEmptyUpper(raw json.RawMessage, keys ...string) (json.RawMessage, error } } +// targetKeys is the set of JSON field names whose string values to uppercase. +type targetKeys map[string]bool + +// upperDecodedKeys recursively uppercases targeted string fields in a decoded +// JSON value, walking objects and arrays and leaving other values untouched. +func upperDecodedKeys(value any, targeted targetKeys) any { + switch node := value.(type) { + case map[string]any: + for key, child := range node { + childStr, isString := child.(string) + if targeted[key] && isString { + node[key] = strings.ToUpper(childStr) + } else { + node[key] = upperDecodedKeys(child, targeted) + } + } + return node + case []any: + for i, child := range node { + node[i] = upperDecodedKeys(child, targeted) + } + return node + default: + return value + } +} + +// UpperRawJSONKeys returns a copy of raw with the string values under any of the +// named keys uppercased at any depth. Empty input is returned unchanged and the +// input is not mutated. +func UpperRawJSONKeys(raw json.RawMessage, keys ...string) (json.RawMessage, error) { + if len(raw) == 0 { + return raw, nil + } + var decoded any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + if err := decoder.Decode(&decoded); err != nil { + return nil, err + } + targeted := make(targetKeys, len(keys)) + for _, key := range keys { + targeted[key] = true + } + return json.Marshal(upperDecodedKeys(decoded, targeted)) +} + func StripEmptyEntries(data map[string]any) { for key, value := range data { if isEmpty(reflect.ValueOf(value)) { diff --git a/models/web-app.go b/models/web-app.go index 003b79c..41ba583 100644 --- a/models/web-app.go +++ b/models/web-app.go @@ -38,6 +38,7 @@ func (s WebApp) MarshalJSON() ([]byte, error) { a.Id = strings.ToUpper(a.Id) a.SubscriptionId = strings.ToUpper(a.SubscriptionId) a.ResourceGroupId = strings.ToUpper(a.ResourceGroupId) + a.ResourceGroupName = strings.ToUpper(a.ResourceGroupName) a.TenantId = strings.ToUpper(a.TenantId) a.Identity = UpperManagedIdentity(a.Identity) return json.Marshal(a)