diff --git a/client/client.go b/client/client.go
index 70b57e76..840039a2 100644
--- a/client/client.go
+++ b/client/client.go
@@ -192,6 +192,7 @@ type azureClient struct {
type AzureGraphClient interface {
GetAzureADOrganization(ctx context.Context, selectCols []string) (*azure.Organization, error)
+ GetAzureADTenantInfoById(ctx context.Context, tenantId string) (azure.Tenant, error)
ListAzureADGroups(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Group]
ListAzureADGroupMembers(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage]
@@ -202,6 +203,7 @@ type AzureGraphClient interface {
ListAzureADUsers(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.User]
ListAzureADRoleAssignments(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.UnifiedRoleAssignment]
ListAzureADRoles(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Role]
+ ListAzureADPartners(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Partner]
ListAzureADServicePrincipalOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage]
ListAzureADServicePrincipals(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.ServicePrincipal]
ListAzureDeviceRegisteredOwners(ctx context.Context, objectId string, params query.GraphParams) <-chan AzureResult[json.RawMessage]
diff --git a/client/partners.go b/client/partners.go
new file mode 100644
index 00000000..5ce53f1d
--- /dev/null
+++ b/client/partners.go
@@ -0,0 +1,41 @@
+// Copyright (C) 2026 Specter Ops, Inc.
+//
+// This file is part of AzureHound.
+//
+// AzureHound is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// AzureHound 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package client
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/bloodhoundad/azurehound/v2/client/query"
+ "github.com/bloodhoundad/azurehound/v2/constants"
+ "github.com/bloodhoundad/azurehound/v2/models/azure"
+)
+
+// ListAzureADPartners
+// Attempts to list partners using the (undocumented) `/directory/partners` API that can be
+// seen being called when visiting partner relationships tab in Entra ID
+func (s *azureClient) ListAzureADPartners(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.Partner] {
+ var (
+ out = make(chan AzureResult[azure.Partner])
+ path = fmt.Sprintf("/%s/directory/partners", constants.GraphApiVersion)
+ )
+
+ go getAzureObjectList[azure.Partner](s.msgraph, ctx, path, params, out)
+
+ return out
+}
diff --git a/client/role_assignments.go b/client/role_assignments.go
index f742f545..8aaa9515 100644
--- a/client/role_assignments.go
+++ b/client/role_assignments.go
@@ -30,7 +30,7 @@ import (
func (s *azureClient) ListAzureADRoleAssignments(ctx context.Context, params query.GraphParams) <-chan AzureResult[azure.UnifiedRoleAssignment] {
var (
out = make(chan AzureResult[azure.UnifiedRoleAssignment])
- path = fmt.Sprintf("/%s/roleManagement/directory/roleAssignments", constants.GraphApiVersion)
+ path = fmt.Sprintf("/%s/roleManagement/directory/roleAssignments", constants.GraphApiBetaVersion)
)
if params.Top == 0 {
diff --git a/client/tenants.go b/client/tenants.go
index 4aa89660..a2339146 100644
--- a/client/tenants.go
+++ b/client/tenants.go
@@ -58,6 +58,21 @@ func (s *azureClient) GetAzureADTenants(ctx context.Context, includeAllTenantCat
}
}
+func (s *azureClient) GetAzureADTenantInfoById(ctx context.Context, tenantId string) (azure.Tenant, error) {
+ var (
+ path = fmt.Sprintf("/%s/tenantRelationships/findTenantInformationByTenantId(tenantId='%s')", constants.GraphApiVersion, tenantId)
+ headers map[string]string
+ response azure.Tenant
+ )
+ if res, err := s.msgraph.Get(ctx, path, query.GraphParams{}, headers); err != nil {
+ return response, err
+ } else if err := rest.Decode(res.Body, &response); err != nil {
+ return response, err
+ } else {
+ return response, nil
+ }
+}
+
// ListAzureADTenants https://learn.microsoft.com/en-us/rest/api/subscription/tenants/list?view=rest-subscription-2020-01-01
func (s *azureClient) ListAzureADTenants(ctx context.Context, includeAllTenantCategories bool) <-chan AzureResult[azure.Tenant] {
var (
diff --git a/cmd/list-azure-ad.go b/cmd/list-azure-ad.go
index be13b148..eab31efe 100644
--- a/cmd/list-azure-ad.go
+++ b/cmd/list-azure-ad.go
@@ -76,7 +76,8 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{
servicePrincipals2 = make(chan interface{})
servicePrincipals3 = make(chan interface{})
- tenants = make(chan interface{})
+ tenants = make(chan interface{})
+ partnerTenants = make(chan interface{})
)
// Enumerate Apps, AppOwners and AppMembers
@@ -99,6 +100,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{
// Enumerate Tenants
pipeline.Tee(ctx.Done(), listTenants(ctx, client), tenants)
+ pipeline.Tee(ctx.Done(), listPartners(ctx, client), partnerTenants)
// Enumerate Users
users := listUsers(ctx, client)
@@ -130,6 +132,7 @@ func listAllAD(ctx context.Context, client client.AzureClient) <-chan interface{
servicePrincipalOwners,
servicePrincipals,
tenants,
+ partnerTenants,
users,
unifiedRoleEligibilitySchedules,
unifiedRoleManagementPolicyAssignments,
diff --git a/cmd/list-partners.go b/cmd/list-partners.go
new file mode 100644
index 00000000..18a32113
--- /dev/null
+++ b/cmd/list-partners.go
@@ -0,0 +1,224 @@
+// Copyright (C) 2026 Specter Ops, Inc.
+//
+// This file is part of AzureHound.
+//
+// AzureHound is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// AzureHound 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package cmd
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "os/signal"
+ "regexp"
+ "time"
+
+ "github.com/bloodhoundad/azurehound/v2/client"
+ "github.com/bloodhoundad/azurehound/v2/client/query"
+ "github.com/bloodhoundad/azurehound/v2/enums"
+ "github.com/bloodhoundad/azurehound/v2/models"
+ "github.com/bloodhoundad/azurehound/v2/models/azure"
+ "github.com/bloodhoundad/azurehound/v2/panicrecovery"
+ "github.com/bloodhoundad/azurehound/v2/pipeline"
+ "github.com/spf13/cobra"
+)
+
+var externalLinkSuffix = regexp.MustCompile(`\s@\([^)]*\)`)
+
+func init() {
+ listRootCmd.AddCommand(listPartnersCmd)
+}
+
+var listPartnersCmd = &cobra.Command{
+ Use: "partners",
+ Long: "Lists Azure Active Directory Delegated Partners",
+ Run: listPartnersCmdImpl,
+ SilenceUsage: true,
+}
+
+func listPartnersCmdImpl(cmd *cobra.Command, args []string) {
+ ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, os.Kill)
+ defer gracefulShutdown(stop)
+
+ log.V(1).Info("testing connections")
+ azClient := connectAndCreateClient()
+ log.Info("collecting azure active directory delegated partners...")
+ start := time.Now()
+ stream := listPartners(ctx, azClient)
+ panicrecovery.HandleBubbledPanic(ctx, stop, log)
+ outputStream(ctx, stream)
+ duration := time.Since(start)
+ log.Info("collection completed", "duration", duration.String())
+}
+
+func listPartners(ctx context.Context, client client.AzureClient) <-chan interface{} {
+ out := make(chan interface{})
+
+ go func() {
+ defer panicrecovery.PanicRecovery()
+ defer close(out)
+ count := 0
+ partnerTenants := make(map[string]azure.Tenant, 10)
+
+ for partner := range client.ListAzureADPartners(ctx, query.GraphParams{}) {
+ if partner.Error != nil {
+ log.Error(partner.Error, "unable to continue processing partners")
+ return
+ }
+
+ log.V(2).Info("found partner", "companyName", partner.Ok.CompanyName, "partnerTenantId", partner.Ok.PartnerTenantId)
+ count++
+
+ // Begin by fetching the partner tenant information
+ externalTenant, err := client.GetAzureADTenantInfoById(ctx, partner.Ok.PartnerTenantId)
+ if err != nil {
+ log.Error(err, "failed to retrieve tenant information for external partner", "companyName", partner.Ok.CompanyName, "partnerTenantId", partner.Ok.PartnerTenantId)
+ }
+
+ externalTenant.Id = fmt.Sprintf("/tenants/%s", externalTenant.TenantId)
+ externalTenant.TenantType = partner.Ok.CompanyType
+
+ if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{
+ Kind: enums.KindAZTenant,
+ Data: models.Tenant{
+ Tenant: externalTenant,
+ External: true,
+ },
+ }); !ok {
+ return
+ }
+
+ partnerTenants[externalTenant.TenantId] = externalTenant
+ }
+ log.Info("finished listing all delegated partners", "count", count)
+
+ count = 0
+
+ // This part is a bit hacky but i'll try to explain what's going on:
+ //
+ // For partners, associated pricipal data is stored in their tenant.
+ // This means that you unfortunately can't just directly query our own
+ // list of groups/users/service principals and get back the principal
+ // information directly. For some reason Microsoft decided to lock this
+ // info behind calls that let you query information via `$expand` queries.
+ //
+ // While I'd love to filter based on `principalOrganizationId`, this field
+ // seems to be some dynamic magic field on the backend and therefor can't be
+ // filtered on. Morover, if you just use `$expand` on `principal` and try to
+ // list all role assignments, you still won't get the information you're looking
+ // for. So far the only way I'm able to reliably filter external tenant's
+ // information is by passing a `roleDefinitionId` filter on `roleAssignments`
+ // which results in the `principal` field and the `principalOrganizationId` field
+ // being present.
+ //
+ // If you find a more efficient way of getting this info I'd love to see an
+ // improved version :)
+ observedPrincipalIds := make(map[string]bool)
+
+ for role := range client.ListAzureADRoles(ctx, query.GraphParams{}) {
+ if role.Error != nil {
+ log.Error(role.Error, "unable to continue processing partner roles")
+ break
+ }
+
+ for item := range client.ListAzureADRoleAssignments(ctx, query.GraphParams{
+ Filter: fmt.Sprintf("roleDefinitionId eq '%s'", role.Ok.Id),
+ Expand: "principal",
+ }) {
+ if item.Error != nil {
+ log.Error(item.Error, "unable to continue processing partner role assignments")
+ break
+ }
+
+ tenant, exists := partnerTenants[item.Ok.PrincipalOrganizationId]
+ if !exists {
+ continue
+ }
+
+ var header struct {
+ Type string `json:"@odata.type"`
+ Id string `json:"Id"`
+ DisplayName string `json:"DisplayName,omitempty"`
+ }
+
+ if err := json.Unmarshal(item.Ok.Principal, &header); err != nil {
+ log.Error(err, "unable to determine principal type")
+ continue
+ }
+
+ if _, ok := observedPrincipalIds[header.Id]; ok {
+ continue
+ }
+
+ var (
+ kind enums.Kind
+ data any
+ )
+
+ switch header.Type {
+ case "#microsoft.graph.user":
+ var user azure.User
+ if err := json.Unmarshal(item.Ok.Principal, &user); err != nil {
+ log.Error(err, "unable to unmarshal user principal")
+ continue
+ }
+ user.DisplayName = externalLinkSuffix.ReplaceAllString(user.DisplayName, "")
+ kind = enums.KindAZUser
+ data = models.User{User: user, TenantId: tenant.TenantId, TenantName: tenant.DisplayName}
+ log.V(2).Info("found partner user information", "id", item.Ok.Id)
+
+ case "#microsoft.graph.group":
+ var group azure.Group
+ if err := json.Unmarshal(item.Ok.Principal, &group); err != nil {
+ log.Error(err, "unable to unmarshal group principal")
+ continue
+ }
+ group.DisplayName = externalLinkSuffix.ReplaceAllString(group.DisplayName, "")
+ kind = enums.KindAZGroup
+ data = models.Group{Group: group, TenantId: tenant.TenantId, TenantName: tenant.DisplayName}
+ log.V(2).Info("found partner group information", "id", item.Ok.Id)
+
+ case "#microsoft.graph.servicePrincipal":
+ var sp azure.ServicePrincipal
+ if err := json.Unmarshal(item.Ok.Principal, &sp); err != nil {
+ log.Error(err, "unable to unmarshal service principal")
+ continue
+ }
+ sp.DisplayName = externalLinkSuffix.ReplaceAllString(sp.DisplayName, "")
+ kind = enums.KindAZServicePrincipal
+ data = models.ServicePrincipal{ServicePrincipal: sp, TenantId: tenant.TenantId, TenantName: tenant.DisplayName}
+ log.V(2).Info("found partner service principal information", "id", item.Ok.Id)
+
+ default:
+ log.V(2).Info("skipping unknown principal type", "type", header.Type)
+ continue
+ }
+
+ observedPrincipalIds[header.Id] = true
+
+ if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{Kind: kind, Data: data}); !ok {
+ break
+ }
+
+ count++
+ }
+ }
+
+ log.Info("finished listing all delegated partner principals", "count", count)
+ }()
+
+ return out
+}
diff --git a/cmd/list-tenants.go b/cmd/list-tenants.go
index 867ee65d..dbc3da1b 100644
--- a/cmd/list-tenants.go
+++ b/cmd/list-tenants.go
@@ -71,6 +71,7 @@ func listTenants(ctx context.Context, client client.AzureClient) <-chan interfac
Data: models.Tenant{
Tenant: collectedTenant,
Collected: true,
+ External: false,
},
}); !ok {
return
@@ -89,7 +90,8 @@ func listTenants(ctx context.Context, client client.AzureClient) <-chan interfac
if ok := pipeline.SendAny(ctx.Done(), out, AzureWrapper{
Kind: enums.KindAZTenant,
Data: models.Tenant{
- Tenant: item.Ok,
+ Tenant: item.Ok,
+ External: false,
},
}); !ok {
return
diff --git a/models/azure/partner.go b/models/azure/partner.go
new file mode 100644
index 00000000..bba4c355
--- /dev/null
+++ b/models/azure/partner.go
@@ -0,0 +1,71 @@
+// Copyright (C) 2026 Specter Ops, Inc.
+//
+// This file is part of AzureHound.
+//
+// AzureHound is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// AzureHound 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 General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+package azure
+
+// Specifies a delegated third-party partner object inside of Entra ID.
+// A visual overview can be seen at
+// More information
+type Partner struct {
+ // Tenant ID of the external partner
+ PartnerTenantId string `json:"partnerTenantId,omitempty"`
+
+ // What kind of external partner?
+ // Scraped a bunch of possible values from:
+ //
+ // Observed variants:
+ // - microsoftSupport
+ // - breadthPartner
+ // - breadthPartnerDelegatedAdmin
+ // - syndicatePartner
+ // - resellerPartnerDelegatedAdmin (reseller)
+ // - valueAddedResellerPartnerDelegatedAdmin (indiret reseller)
+ //
+ CompanyType string `json:"companyType,omitempty"`
+
+ // The name of the external partner
+ CompanyName string `json:"companyName,omitempty"`
+
+ // Link to the partner sales portal
+ CommerceUrl string `json:"commerceUrl,omitempty"`
+
+ // Link to the partner help portal
+ HelpUrl string `json:"helpUrl,omitempty"`
+
+ // Link to the partner support portal
+ // Unsure how this is different from the `HelpUrl`
+ SupportUrl string `json:"supportUrl,omitempty"`
+
+ // List of telephone numbers used for support
+ SupportTelephones []string `json:"supportTelephones,omitempty"`
+
+ // List of e-mail addresses used for support
+ SupportEmails []string `json:"supportEmails,omitempty"`
+
+ // What type of contract does the current tenant have with the partner?
+ // Scraped values from:
+ //
+ // Observed variants:
+ // - resellerPartnerContract
+ // - breadthPartnerContract
+ ContractType string `json:"contractType,omitempty"`
+
+ // List of Role ID's
+ // Unsure what type this is since the observed versions had no data
+ // Assuming string since that's the most logical based on the naming convention
+ RoleIDs []string `json:"roleIds,omitempty"`
+}
diff --git a/models/azure/unified_role_assignment.go b/models/azure/unified_role_assignment.go
index fc60a5fd..592dc09e 100644
--- a/models/azure/unified_role_assignment.go
+++ b/models/azure/unified_role_assignment.go
@@ -24,13 +24,17 @@ type UnifiedRoleAssignment struct {
// Identifier of the role definition the assignment is for.
// Read only.
- // Supports $filer (eq, in).
+ // Supports $filter (eq, in).
RoleDefinitionId string `json:"roleDefinitionId,omitempty"`
// Identifier of the principal to which the assignment is granted.
// Supports $filter (eq, in).
PrincipalId string `json:"principalId,omitempty"`
+ // Identifier of the organization (tenant) the principal belongs to
+ // Read only and does not support $filter.
+ PrincipalOrganizationId string `json:"principalOrganizationId,omitempty"`
+
// Identifier of the directory object representing the scope of the assignment.
// Either this property or appScopeId is required.
// The scope of an assignment determines the set of resources for which the principal has been granted access.
diff --git a/models/tenant.go b/models/tenant.go
index 7fed704a..102a3054 100644
--- a/models/tenant.go
+++ b/models/tenant.go
@@ -22,4 +22,5 @@ import "github.com/bloodhoundad/azurehound/v2/models/azure"
type Tenant struct {
azure.Tenant
Collected bool `json:"collected,omitempty"`
+ External bool `json:"external,omitempty"`
}