# Azure Discovery

Azure Discovery is a lightweight Azure tenant mapper that enumerates subscriptions and resources via Azure Resource Graph, normalizes the results, and renders an interactive dependency graph. The tool exposes both a Typer-based CLI (`azure-discovery`) and a FastAPI surface so the same discovery workflow can be automated or embedded in other services.

The package is published on [PyPI](https://pypi.org/project/azure-discovery/) as **azure-discovery** and can be installed with `pip install azure-discovery`.

## Core capabilities

- Builds environment-aware credential chains (Azure CLI + DefaultAzureCredential) with guardrails for unsupported clouds.
- Queries Azure Resource Graph with include/exclude filters, tag constraints, and resource group scopes.
- Resolves subscriptions automatically when not provided and de-duplicates resources for consistent graph IDs.
- Produces JSON summaries, console metrics, and PyVis HTML graphs for quick triage.
- Optionally enumerates Entra ID objects via Microsoft Graph (organization/domains, users, groups, applications, service principals, conditional access policies, risky users) with bounded relationship expansion.
- Optionally enumerates Azure RBAC role assignments and definitions with principal-to-resource relationship mapping.
- Optionally enumerates Defender for Cloud security alerts, assessments, and secure scores for security posture analysis.
- Offers identical request/response contracts (Pydantic models) across CLI and API, following the Receive an Object, Return an Object (RORO) pattern.
- Supports all Azure clouds: public, Government (GCC/GCC-H), China, Germany, and Azure Stack.
- Adaptive rate control and intelligent batching for large-scale tenant discovery.
- API hardening with pluggable authentication (Azure AD, API key), rate limiting, audit logging, and CORS.

## Installation

**From PyPI (recommended):**

```bash
pip install azure-discovery
```

**With optional development dependencies:**

```bash
pip install azure-discovery[dev]
```

**From source (e.g. for development or when embedded in another repo):**

```bash
git clone https://github.com/maravedi/AzureDiscovery.git
cd AzureDiscovery
pip install -e .[dev]
```

## Package layout

When installed, the package provides the **azure_discovery** Python package:

```
azure_discovery/
  __init__.py       # run_discovery, AzureDiscoveryRequest, AzureDiscoveryResponse, etc.
  cli.py            # Typer command surface (entry point: azure-discovery)
  api.py            # FastAPI app for /discover and visualization endpoints
  orchestrator.py   # Async coordinator for enumeration + visualization
  adt_types/        # Pydantic models and custom exceptions
  enumerators/      # Resource Graph query builder and normalization
  reporting/        # Console logging and HTML/PyVis graph generation
  utils/            # Azure SDK clients, graph helpers, structured logging
```

## Usage

### CLI

ARM-only discovery:

```bash
azure-discovery discover \
  --tenant-id <tenant-guid> \
  --subscription <sub-id-1> --subscription <sub-id-2> \
  --include-type "Microsoft.Compute/virtualMachines" \
  --resource-group core-infra \
  --required-tag environment=prod \
  --visualization-output-dir artifacts/graphs
```

ARM + Entra discovery (example):

```bash
azure-discovery discover \
  --tenant-id <tenant-guid> \
  --subscription <sub-id-1> \
  --include-entra \
  --entra-group-membership-max-groups 50 \
  --entra-group-membership-max-members-per-group 200
```

ARM + RBAC discovery (example):

```bash
azure-discovery discover \
  --tenant-id <tenant-guid> \
  --subscription <sub-id-1> \
  --include-rbac-assignments \
  --include-rbac-definitions \
  --rbac-scope "/subscriptions/<sub-id-1>"
```

ARM + Defender for Cloud discovery (example):

```bash
azure-discovery discover \
  --tenant-id <tenant-guid> \
  --subscription <sub-id-1> \
  --include-defender-cloud \
  --defender-alert-severity High --defender-alert-severity Critical \
  --defender-alert-status Active
```

Using a config file (JSON/TOML/YAML):

```bash
# CLI flags override file values
azure-discovery discover --config examples/config.example.toml

# You can still override specific values
azure-discovery discover --config examples/config.example.toml --include-entra --entra-max-objects 10000
```

Examples:

- [examples/config.example.toml](examples/config.example.toml)
- [examples/config.example.yaml](examples/config.example.yaml)

Configuration docs:

- [docs/configuration.md](docs/configuration.md)

Tip: you can generate a JSON starter config by running with `--preview-request` and saving stdout.

Run as a module from source (from the repo root):

```bash
python -m azure_discovery.cli discover --help
python -m azure_discovery.cli discover --tenant-id <tenant-guid> --environment azure_gov [options...]
```

### FastAPI

Run the server:

```bash
uvicorn azure_discovery.api:app --host 0.0.0.0 --port 8000 --reload
```

Optional: set `AZURE_DISCOVERY_CONFIG=/path/to/discovery.toml` to apply default values to incoming requests (request body fields win).

Health check:

```bash
curl http://localhost:8000/healthz
```

Discovery request (example):

```bash
curl -X POST http://localhost:8000/discover \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "<tenant-guid>",
    "environment": "azure_public",
    "subscriptions": ["<sub-id>"]
  }'
```

Enable Entra + relationship expansion (example):

```bash
curl -X POST http://localhost:8000/discover \
  -H "Content-Type: application/json" \
  -d '{
    "tenant_id": "<tenant-guid>",
    "environment": "azure_public",
    "subscriptions": ["<sub-id>"],
    "include_entra": true,
    "include_relationships": true,
    "entra_group_membership_max_groups": 50,
    "entra_group_membership_max_members_per_group": 200
  }'
```

Download visualization:

```bash
curl http://localhost:8000/visuals/<file-name> --output graph.html
```

### Python

Async usage:

```python
from azure_discovery import run_discovery
from azure_discovery.adt_types import AzureDiscoveryRequest, AzureEnvironment

request = AzureDiscoveryRequest(
    tenant_id="<tenant-guid>",
    environment=AzureEnvironment.AZURE_PUBLIC,
    subscriptions=["<sub-id>"],
    include_entra=True,
)

response = await run_discovery(request)
print(len(response.nodes), len(response.relationships), response.html_report_path)
```

Load from config file:

```python
from pathlib import Path

from azure_discovery.utils.config_files import load_request_from_file

request = load_request_from_file(Path("examples/config.example.yaml"))
```

Sync script wrapper:

```python
import asyncio

from azure_discovery import run_discovery
from azure_discovery.adt_types import AzureDiscoveryRequest, AzureEnvironment

request = AzureDiscoveryRequest(
    tenant_id="<tenant-guid>",
    environment=AzureEnvironment.AZURE_PUBLIC,
)

response = asyncio.run(run_discovery(request))
print(response.total_resources)
```

## Entra ID discovery

When `include_entra` is enabled, Azure Discovery queries Microsoft Graph and emits normalized nodes using a `graph://...` ID namespace to avoid collisions with Azure Resource Manager (ARM) IDs.

### Entra node types

Typical Entra collections include:

- Microsoft.Graph/Organization and Microsoft.Graph/Domain
- Microsoft.Graph/User and Microsoft.Graph/Group
- Microsoft.Graph/Application and Microsoft.Graph/ServicePrincipal
- Microsoft.Graph/ConditionalAccessPolicy
- Microsoft.Graph/RiskyUser

### Entra relationships

When `include_relationships` is enabled, Azure Discovery can emit bounded edges:

- `has_domain` (organization -> domain)
- `has_member` (group -> member) when group membership expansion is enabled
- `has_owner` (application/servicePrincipal -> owner) when ownership expansion is enabled
- `appId` (servicePrincipal -> application) correlation edges when both are enumerated

All relationship expansion is capped by request parameters (see CLI options below) to avoid blowing up graphs in large tenants.

## Azure RBAC discovery

When `include_rbac_assignments` or `include_rbac_definitions` is enabled, Azure Discovery enumerates Azure role-based access control (RBAC) data:

- **Role assignments**: Who has what access to which resources
- **Role definitions**: Built-in and custom role definitions with their permissions
- **RBAC relationships**: Principal → RoleAssignment → Resource graph edges (when `include_relationships` is enabled)

This capability is useful for security posture assessment, access reviews, and understanding the permission landscape across your Azure estate.

## Defender for Cloud discovery

When `include_defender_cloud` is enabled, Azure Discovery enumerates security findings from Microsoft Defender for Cloud:

- **Security alerts**: Active threats, suspicious activity, and security incidents detected across your Azure resources. Each alert includes MITRE ATT&CK tactics and techniques, affected resources, remediation steps, and severity ratings.
- **Security assessments**: Vulnerability findings, compliance recommendations, and security best practices. Assessments identify configuration gaps and provide remediation guidance.
- **Secure scores**: Subscription-level security posture scores that quantify your current security state (e.g., 42.5/100).

### Defender node types

- **Microsoft.Security/alerts**: Security alerts with properties like severity (High, Medium, Low, Informational), status (Active, Resolved, Dismissed), MITRE ATT&CK techniques, and affected resources.
- **Microsoft.Security/assessments**: Security assessments with severity, status (Healthy, Unhealthy, NotApplicable), categories (Data, Network, Compute, etc.), and remediation descriptions.
- **Microsoft.Security/secureScores**: Subscription security posture scores with current/max values and percentage.

### Defender relationships

When `include_relationships` is enabled, Azure Discovery creates edges between security findings and affected resources:

- `affects` (alert -> resource): Links security alerts to the VMs, storage accounts, or other resources they impact.
- `affects` (assessment -> resource): Links security assessments to the resources that have vulnerabilities or misconfigurations.

These relationships enable security-focused graph queries like "Show me all High severity alerts affecting production VMs" or "Which resources have the most unhealthy assessments?"

### Defender filtering

You can filter security findings to reduce noise and focus on critical issues:

```bash
# Only High and Critical severity alerts that are Active
azure-discovery discover \
  --tenant-id <tenant-guid> \
  --include-defender-cloud \
  --defender-alert-severity High --defender-alert-severity Critical \
  --defender-alert-status Active

# Only Unhealthy assessments (skip Healthy and NotApplicable)
azure-discovery discover \
  --tenant-id <tenant-guid> \
  --include-defender-cloud \
  --defender-assessment-status Unhealthy

# Alerts only (disable assessments and scores)
azure-discovery discover \
  --tenant-id <tenant-guid> \
  --include-defender-cloud \
  --no-defender-include-assessments \
  --no-defender-include-secure-scores
```

Config file example (YAML):

```yaml
include_defender_cloud: true
defender_config:
  include_security_alerts: true
  include_security_assessments: true
  include_secure_scores: true
  alert_severity_filter:
    - High
    - Critical
  alert_status_filter:
    - Active
  assessment_status_filter:
    - Unhealthy
```

This capability is useful for security operations, vulnerability management, compliance tracking, and prioritizing remediation efforts based on actual threats and exposures.

## Prerequisites

- Python 3.11+
- Azure CLI 2.60+ (optional, used when `--prefer-cli` is set) or service principal credentials exported as `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_CLIENT_SECRET`
- Azure Resource Graph access (Reader or above on the subscriptions you plan to scan)
- Microsoft Graph access (only required when using `--include-entra`)
- Network egress to `management.azure.com` and `api.azure.com` (and to the Graph endpoint for the cloud you select)

## Required permissions

### Azure Resource Graph (ARM)

| Capability | Minimum RBAC role | Scope recommendation |
| ---------- | ----------------- | -------------------- |
| Run Resource Graph queries | `Reader`, `Resource Graph Reader`, or any custom role with `Microsoft.ResourceGraph/*/read` | Every subscription you plan to inventory or the parent management group |
| Auto-discover subscriptions (when `--subscription` is omitted) | `Reader` on the management group or `Directory.Read.All` consent for service principals | Tenant root (`/providers/Microsoft.Management/managementGroups/<root>`) |
| Register Microsoft.ResourceGraph (one-time) | `Contributor` or `Owner` | Each subscription being scanned |
| Read role assignments | `Reader` or `Role Based Access Control Administrator (read-only)` | Subscription or management group |
| Read role definitions | `Reader` | Subscription |
| Read Defender for Cloud alerts and assessments | `Reader` or `Security Reader` | Subscription |

The tool never mutates resources, but it cannot enumerate subscriptions or call Resource Graph unless the identity has at least `Reader` at the relevant scope. Grant the narrowest scope that still covers your target estate.

### Microsoft Graph (Entra)

Azure Discovery uses Microsoft Graph delegated permissions when running as a signed-in user (for example, Azure CLI/device code flows), and application permissions when running headless (service principal / managed identity).

The following table is a practical starting point for *read-only* discovery. Always follow least privilege, and prefer narrower resource-specific permissions over broad directory-wide permissions where possible.

| Discovery area | Typical endpoints | Delegated permissions | Application permissions | Notes |
| --- | --- | --- | --- | --- |
| Users | `/users` | `User.ReadBasic.All` or `User.Read.All` (or `Directory.Read.All`) | `User.Read.All` (or `Directory.Read.All`) | Guests can't call `/users`. |
| Groups + members | `/groups`, `/groups/{id}/members` | `Group.Read.All` + `GroupMember.Read.All` (or `Directory.Read.All`) | `Group.Read.All` + `GroupMember.Read.All` (or `Directory.Read.All`) | Hidden memberships may require additional permissions depending on tenant settings. |
| Applications + service principals | `/applications`, `/servicePrincipals` | `Application.Read.All` (or `Directory.Read.All`) | `Application.Read.All` (or `Directory.Read.All`) | Needed for enumerating apps/SPs and owner expansion. |
| Conditional Access policies | `/identity/conditionalAccess/policies` | `Policy.Read.All` | `Policy.Read.All` | Delegated access typically requires an Entra role such as Conditional Access Administrator or similar security read roles. |
| Risky users (Identity Protection) | `/identityProtection/riskyUsers` | `IdentityRiskyUser.Read.All` | `IdentityRiskyUser.Read.All` | Requires Entra ID Identity Protection licensing (commonly P2). |

References:

- List users permissions: https://learn.microsoft.com/en-us/graph/api/user-list?view=graph-rest-1.0
- Conditional Access policy list permissions: https://learn.microsoft.com/en-us/graph/api/conditionalaccessroot-list-policies?view=graph-rest-1.0
- Identity Protection API tutorial (role + delegated scope examples): https://learn.microsoft.com/en-us/graph/tutorial-riskdetection-api

### Service principal flow (CLI based)

```
az ad sp create-for-rbac \
  --name azure-discovery-sp \
  --role "Reader" \
  --scopes /subscriptions/<sub-id-1> /subscriptions/<sub-id-2> \
  --years 1

az role assignment create \
  --assignee <appId> \
  --role "Resource Graph Reader" \
  --scope /subscriptions/<sub-id-1>
```

Export the emitted `appId`, `tenant`, and `password` as `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, and `AZURE_CLIENT_SECRET`. Repeat the role assignment command for every subscription or assign at the management group scope (`/providers/Microsoft.Management/managementGroups/<mg-id>`) to cover multiple subscriptions at once.

### User-assigned permissions (Portal)

1. Open Azure Portal → **Subscriptions** → select each target subscription.
2. Navigate to **Access control (IAM)** → **Add** → **Add role assignment**.
3. Pick the `Reader` (or `Resource Graph Reader`) role, then select the user or managed identity that will run AzureDiscovery.
4. If you want automatic subscription discovery, repeat the assignment at the tenant root management group (visible under **Management groups**). Users need the **Azure RBAC Reader** role there.

### Provider registration and validation

Run the following once per subscription to ensure the Resource Graph service is registered and the identity can query it:

```
az account set --subscription <sub-id>
az provider register --namespace Microsoft.ResourceGraph
az graph query -q "Resources | take 1"
```

Successful output from `az graph query` confirms both the provider registration and the assigned role. If the command fails with `AuthorizationFailed`, double-check the scope of the role assignments and replicate them for every subscription you intend to scan.

## Configuration reference

| Option | Description |
| ------ | ----------- |
| `--config` | Path to JSON/TOML/YAML config file (AzureDiscoveryRequest shape). CLI flags override file values. |
| `--tenant-id` | Required Entra ID tenant GUID. |
| `--environment` | Azure cloud (`azure_public`, `azure_gov`, `azure_china`, `azure_germany`, `azure_stack`). |
| `--subscription/-s` | Repeatable flag to scope runs to explicit subscription IDs. Omit to auto-resolve. |
| `--include-entra` | Include Entra ID resources via Microsoft Graph. |
| `--include-rbac-assignments` | Include Azure role assignments in discovery. |
| `--include-rbac-definitions` | Include Azure role definitions (built-in and custom). |
| `--rbac-scope` | Filter role assignments by scope (repeatable). |
| `--include-defender-cloud` | Include Defender for Cloud security findings (alerts, assessments, scores). |
| `--defender-include-alerts/--no-defender-include-alerts` | Include security alerts from Defender for Cloud (default: true when defender enabled). |
| `--defender-include-assessments/--no-defender-include-assessments` | Include security assessments (default: true when defender enabled). |
| `--defender-include-secure-scores/--no-defender-include-secure-scores` | Include secure scores (default: true when defender enabled). |
| `--defender-alert-severity` | Filter alerts by severity: High, Medium, Low, Informational (repeatable). |
| `--defender-alert-status` | Filter alerts by status: Active, Resolved, Dismissed (repeatable). |
| `--defender-assessment-severity` | Filter assessments by severity: High, Medium, Low (repeatable). |
| `--defender-assessment-status` | Filter assessments by status: Healthy, Unhealthy, NotApplicable (repeatable). |
| `--scale-controls/--no-scale-controls` | Enable adaptive rate control for large tenants (default: enabled). |
| `--scale-initial-rps` | Initial requests per second for adaptive rate control (default: 10.0). |
| `--scale-max-concurrent-batches` | Maximum concurrent batch operations (default: 5). |
| `--scale-initial-batch-size` | Initial batch size for paginated operations (default: 1000). |
| `--entra-include-organization/--no-entra-include-organization` | Include organization (tenant root) node. |
| `--entra-include-domains/--no-entra-include-domains` | Include tenant domains. |
| `--entra-include-users/--no-entra-include-users` | Include Entra users. |
| `--entra-include-groups/--no-entra-include-groups` | Include Entra groups. |
| `--entra-include-applications/--no-entra-include-applications` | Include Entra applications. |
| `--entra-include-conditional-access-policies/--no-entra-include-conditional-access-policies` | Include conditional access policies (requires permissions). |
| `--entra-include-risky-users/--no-entra-include-risky-users` | Include risky users (requires permissions). |
| `--entra-group-membership-max-groups` | Max groups to expand membership for (0 disables expansion). |
| `--entra-group-membership-max-members-per-group` | Max members per group during expansion (0 disables expansion). |
| `--entra-ownership-max-apps` | Max applications to expand owners for (0 disables expansion). |
| `--entra-ownership-max-owners-per-app` | Max owners per app during expansion (0 disables expansion). |
| `--entra-sp-ownership-max-sps` | Max service principals to expand owners for (0 disables SP ownership expansion). |
| `--entra-sp-ownership-max-owners-per-sp` | Max owners per service principal during expansion (0 disables expansion). |
| `--include-relationships/--no-include-relationships` | Include inferred and expanded relationships/edges (Graph expansions require this). |
| `--graph-total-max-objects` | Maximum total objects across all Graph collections (0 = unlimited). |
| `--entra-max-objects` | Maximum objects per Entra collection (0 = unlimited). |
| `--include-type` / `--exclude-type` | Filter resource types (case-insensitive). |
| `--resource-group` | Restrict discovery to named resource groups. |
| `--required-tag` | Enforce tag key=value pairs (repeatable). |
| `--prefer-cli` | Place Azure CLI credentials at the front of the chain. |
| `--visualization-output-dir` | Directory for PyVis HTML output (default `artifacts/graphs`). |
| `--visualization-file` | Override the generated HTML file name. |
| `--output/-o` | Write JSON output to file instead of stdout. |
| `--quiet/-q` | Suppress all logs except errors. |
| `--format/-f` | Output format: `json` (default) or `json-compact`. |

Programmatic workflows can instantiate `AzureDiscoveryRequest` directly and call `orchestrator.run_discovery`, receiving an `AzureDiscoveryResponse` that contains resolved subscriptions, normalized nodes, inferred relationships, and an optional `html_report_path`.

### Output and logging separation

By default, the CLI writes JSON results to stdout and logs to stderr. This allows clean piping:

```bash
# Pipe JSON output to jq for filtering
azure-discovery discover --tenant-id <id> | jq '.discovered_subscriptions'

# Write output to file and suppress logs
azure-discovery discover --tenant-id <id> --output results.json --quiet

# Compact JSON output for scripting
azure-discovery discover --tenant-id <id> --format json-compact
```

## Development

### Quick start

```bash
# Install with development dependencies
make install-dev

# Run tests
make test

# Format code
make format

# Run linting
make lint

# Type checking
make typecheck

# Generate coverage report
make coverage
```

### Available make commands

Run `make help` to see all available commands:
- `make install` - Install package dependencies
- `make install-dev` - Install with development dependencies
- `make test` - Run tests with pytest
- `make lint` - Run ruff linter
- `make format` - Format code with ruff
- `make typecheck` - Run mypy type checking
- `make coverage` - Generate test coverage report
- `make clean` - Remove build artifacts and cache
- `make run-api` - Run FastAPI server locally

### Pre-commit hooks

Install pre-commit hooks to automatically run linting and formatting on commit:

```bash
pip install pre-commit
pre-commit install
```

This will run ruff formatting, linting, and mypy type checking before each commit.

### Environment variables

Copy `.env.example` to `.env` and configure your Azure credentials:

```bash
cp .env.example .env
# Edit .env with your credentials
```

For detailed contributing guidelines, see [CONTRIBUTING.md](CONTRIBUTING.md).

## Troubleshooting

- **`AzureClientError: Unable to enumerate subscriptions`** – ensure the identity has at least `Reader` on one subscription and that the Resource Graph service is registered (`az provider register --namespace Microsoft.ResourceGraph`).
- **`AuthorizationFailed` / `Forbidden` (ARM)** – confirm the identity has `Reader` (or `Resource Graph Reader`) on every subscription (or parent management group) you are scanning, and that your current Azure CLI context is pointing at a subscription you can read (`az account show`).
- **`Resource Graph query failure`** – check that the tenant/subscription pair belongs to the same cloud you selected, and verify network egress to the relevant `resource_manager` endpoint (see `_ENVIRONMENT_MAP` in `azure_discovery.utils.azure_clients`).
- **`403 Forbidden` / `Authorization_RequestDenied` / `Insufficient privileges` (Graph)** – this usually means required Microsoft Graph permissions were not admin-consented, or (for delegated runs) the signed-in user lacks the Entra admin role required for that dataset (commonly Conditional Access / Identity Protection). If you don't need those datasets, disable them with `--no-entra-include-conditional-access-policies` and/or `--no-entra-include-risky-users`.
- **Risky users missing / empty** – the Identity Protection APIs require additional permissions and licensing (commonly Entra ID P2); if you don't have that, disable with `--no-entra-include-risky-users`.
- **Defender for Cloud alerts/assessments empty** – verify Defender for Cloud is enabled on the subscription(s) being scanned. The tool gracefully handles subscriptions without Defender enabled (404 errors are logged as warnings).
- **Preflight auth check (CLI)** – run `azure-discovery discover --tenant-id <tenant-guid> --validate-auth --probe-connectivity` to confirm the credential chain can acquire tokens for both ARM and Graph (note: this currently validates token acquisition only).
- **`VisualizationError: Failed to render HTML graph`** – confirm the `--visualization-output-dir` path exists and is writable; the PyVis writer does not auto-create directories unless it has permissions on each parent.
- **`401` or `interaction_required` errors** – when running non-interactively, use a service principal credential chain and set `AZURE_CLIENT_SECRET`; the default chain will otherwise attempt to launch an interactive browser flow.
- **Empty graph output** – verify filters are not mutually exclusive (e.g., mixing include/exclude for the same type).

## Feature status and roadmap

### ✅ Implemented features

- **Azure RBAC graph** – Role assignments and role definitions are fully enumerated and visualized with principal-to-resource relationship mapping.
- **API hardening** – FastAPI includes pluggable authentication (Azure AD, API key), rate limiting, audit logging, CORS configuration, and API versioning.
- **Scale controls** – Adaptive rate control and intelligent batching automatically handle API throttling and optimize throughput for large tenants.
- **Defender for Cloud** – Security alerts, vulnerability assessments, secure scores, and compliance status are integrated with filtering by severity and status.

### 🔄 Planned features

- **Change tracking** – Persistent storage backend with snapshot diffing to track changes over time.
- **Non-Entra SaaS surfaces** – M365 and Microsoft Purview enumerators for broader coverage beyond Azure and Entra ID.

For detailed implementation plans, see [docs/IMPLEMENTATION_ROADMAP.md](docs/IMPLEMENTATION_ROADMAP.md).

## Publishing to PyPI (maintainers)

To publish a new version to PyPI:

1. Bump `version` in `pyproject.toml`.
2. Ensure tests pass: `pip install -e .[dev] && pytest`.
3. Build: `python -m build`.
4. Upload: `twine upload dist/azure-discovery-<version>*` (requires PyPI credentials or token).

The package uses a single top-level package **azure_discovery** to avoid namespace conflicts on install. The console script **azure-discovery** is provided by the `[project.scripts]` entry in `pyproject.toml`.
