Skip to content

Azure IPAM v4.0.0 Release - #377

Open
DCMattyG wants to merge 282 commits into
mainfrom
ipam-3.7.0
Open

DCMattyG wants to merge 282 commits into
mainfrom
ipam-3.7.0

Conversation

@DCMattyG

@DCMattyG DCMattyG commented Mar 11, 2026

Copy link
Copy Markdown
Contributor

Azure IPAM v4.0.0

This is a major release that delivers significant framework upgrades, a data grid migration, authentication modernization, comprehensive documentation overhaul, revamped examples, and numerous bug fixes.


Major Framework & Dependency Upgrades

  • React 18 → 19: Full migration including removal of forwardRef wrappers (ref-as-prop), removal of PropTypes, explicit null for useRef() calls, and migration of LoadingButton to Button
  • MSAL v4 → v5 (@azure/msal-browser 4.x → 5.x, @azure/msal-react 3.x → 5.x): Removed obsolete config, consolidated event types, fixed silent token timeout recovery (timed_out error code), and prevented iframe fallback timeout loops
  • Inovua React Data Grid → AG Grid (ag-grid-community / ag-grid-react 36.x): Complete migration to AG Grid including centralized DataGrid component, custom styling, column state persistence, unified data loading overlays, and custom cell renderers (drill-down, info, progress)
  • Vite 7 → 8 (vite 7.x → 8.x, @vitejs/plugin-react 5.x → 6.x): Migrated to Vite 8 which replaces Rollup with Rolldown and esbuild with Oxc for bundling, transforms, and minification. Removed vite-plugin-eslint2 (redundant with editor-based linting and incompatible with Vite 8)
  • ESLint 10: Upgraded from ESLint 9 to 10 with modern React linting plugins (@eslint-react/eslint-plugin v5, eslint-plugin-react-hooks v7 — which now consolidates the React Compiler lint rules per the React Compiler 1.0 release), replacing eslint-plugin-react. Added dist/ ignore, fixed no-useless-assignment violations, and removed unused eslint-plugin-jest. Resolved all remaining lint warnings as part of the React 19 modernization — useContextuse, <Context.Provider><Context>, ref naming conventions, stable list keys, hoisted static styled components, migration of SnackbarUtils to notistack's standalone enqueueSnackbar, and moved "ref assigned during render" patterns into useEffect
  • MUI v7 → v9 (@mui/material 7.3.x → 9.4.x, @mui/icons-material 7.3.x → 9.4.x): Major two-version jump. Migrated deprecated component props to the unified slots/slotProps API (largely via @mui/codemod), moved deprecated system props into sx, replaced the removed Unstable_Grid2 with the new default Grid (using size={{ xs: N }}), renamed removed Outline (no "d") icon exports to their Outlined counterparts, and removed @mui/lab (no longer needed — LoadingButton's loading prop is now native to Button)
  • React Router 7 → 8 (react-router 7.x → 8.x): Major version upgrade. The UI uses declarative-mode routing (BrowserRouter / Routes / Route) with all imports already sourced from react-router, so no application code changes were required. v8 raises the minimum runtime to Node.js 22.22.0 (and React 19.2.7+)
  • Dependency swaps and cleanup: moment replaced with dayjs and lodash with lodash-es; removed web-vitals and the React Testing Library packages (@testing-library/jest-dom, @testing-library/react, @testing-library/user-event), which the UI carried without a single test file or test script to use them
  • Updated NPM packages across the board

Engine & Backend

  • Azure Function Blueprints: Implemented Blueprint-based function naming for improved clarity
  • Python dependency cleanup: Removed msal, azure-common, azure-keyvault-secrets, and six; added azure-mgmt-resource-subscriptions to address Azure SDK module separation
  • Python linting: Added pyproject.toml with Ruff linter configuration (pycodestyle, pyflakes, isort) and resolved every resulting violation across the engine
  • Reservation auto-fulfillment no longer duplicates a network's Block association: A Virtual Network with several address spaces drawn from the same Block carries one Reservation ID per space in its X-IPAM-RES-ID tag, and reconciliation evaluates each independently. Fulfilling one appended the network to the Block's association list without first checking whether it was already there, so a network with two Reservation IDs was added twice and one with three, three times — the same network, with the same prefixes, recorded as though it were several. Every consequence followed from that list being walked as data: add_block_utilization counted the network's in-Block prefixes once per entry, so the Block and its Space reported more address space in use than was in use; the removal endpoint deletes the first matching index and returns, so an administrator had to remove the association once per copy, with nothing to explain why it kept reappearing; and re-adding was refused while any copy remained. Fulfillment now reuses an existing association and marks it active rather than appending. Because the reconciliation fix prevents new duplicates but cannot undo the ones already written, a convergence step collapses them during the upgrade; it is guarded to write only where it finds a duplicate, so it is a no-op on a clean database. Affected deployments will see Block and Space utilization fall once it runs — the earlier figure counted the same address space more than once, and no address space is being released (fixes Assignment of multiple reservations from an address block on a single VNET #349)
  • Reservation overlap detection no longer misses an overlap that encloses an existing network: Before auto-fulfilling a Reservation, reconciliation checked the reserved CIDR against every network already associated to the Block with IPNetwork(resv['cidr']) in IPSet(existing_block_cidrs) — a containment test, true only where the reserved range sits entirely inside space already accounted for. The opposite arrangement passed silently: a Reservation containing an already-associated network, or straddling its edge, is not contained by it, so the check found nothing, the Reservation was fulfilled, and its Virtual Network was associated alongside the one it overlaps — 10.43.2.192/26 against an existing 10.43.2.192/28 being the reported shape. That is the one state an IPAM exists to prevent, and once written it persisted, since nothing re-examines existing associations for overlap. The test is now an intersection of the two sets, which is symmetric and catches a partial overlap in either direction. The status raised on detection is renamed from errCIDRExists to errCIDROverlap to describe what is actually being reported; the interface renders both, so a Reservation already carrying the old value still displays correctly. Overlaps already recorded are not unwound automatically, as Azure IPAM cannot know which of the two associations is the one that should not be there — POST /api/tools/cidrCheck enumerates them along with the Space and Block each network belongs to (fixes Overlap in CIDR #376)
  • Endpoint fix: Standalone NICs are now included in the Endpoint list (fixes Standalone NICs not listed in Endpoint node #371)
  • Network associations fix: Resolved improper handling of missing vNETs and vHUBs (fixes Error fetching available IP Block networks #350)
  • Next available network fix: nextAvailableVNet aborted its entire Block list search when the first Block could not satisfy the requested size under smallest_cidr, returning a 500 instead of evaluating the remaining Blocks. max() was called on an empty candidate list, and the resulting ValueError escaped the loop. The same missing guard in nextAvailableSubnet and in single-Block Reservation creation replaced their intended error messages with an unhandled exception (fixes nextAvailableVNet fails when using multiple blocks and smallest_cidr option true if no IP range is available in first block #384)
  • HTTP status codes corrected for client and capacity errors: Conditions the API explicitly anticipates were reported as 500, making them indistinguishable from genuine server faults, prompting pointless client and proxy retries, and counting against server-error metrics. Requests that cannot be satisfied by current allocation state now return 409, as does a JSON patch that conflicts with the stored document; a malformed JSON patch now returns 400. Both already matched the codes used by neighboring checks in the same handlers. Errors originating from Azure, Cosmos DB, or the site configuration are unchanged and still return 500
  • Development reload flag removed from production startup: uvicorn was started with --reload in both production init scripts. Under a read-only run-from-package mount its watcher tracked thousands of files that can never change, and its supervisor process stayed alive when the application crashed — so the container kept running while serving nothing, and health checks failed against an apparently healthy process. A startup failure now exits, so the platform restarts the application and the fault is visible. engine/Dockerfile.dev retains the flag, where hot reload is intended
  • TLS validation moved to the OS trust store: The JWKS fetch backing token validation used requests, which verifies against the CA bundle shipped inside certifi rather than the operating system trust store. WEBSITES_INCLUDE_CLOUD_CERTS populates the OS store, so sovereign cloud roots were present but invisible to that code path — secret cloud (IL6) deployments rejected every token with CERTIFICATE_VERIFY_FAILED while passing in every commercial cloud, where certifi covers the endpoints. Every other outbound call already used aiohttp, which reads the OS store; the JWKS fetch was the lone exception and now matches. The metrics heartbeat moved off requests as well, and a Ruff banned-api rule now fails the build on import requests or import httpx, since the defect is invisible to commercial-cloud testing and would otherwise return unnoticed
  • Entra signing keys cached: Token validation fetched the tenant JWKS document and rebuilt the RSA public key from its JWK on every authenticated request, spending a TLS handshake, an HTTP round trip and a key conversion per API call. Keys are now cached in process following Microsoft's signing key rollover guidance — a one hour refresh with jitter, a twenty four hour hard expiry, and a forced refresh on an unknown kid floored at once per five minutes. Concurrent refreshes collapse to a single fetch, and a failed refresh serves the last known good keys rather than failing closed. The floor also closes an amplification vector, where a flood of tokens bearing bogus kid values previously produced one outbound fetch each on behalf of unauthenticated callers
  • Address space checks no longer pay for full network detail: Every occupancy and utilization check loaded each network in the tenant complete with its subnets, peerings and Cosmos DB parentage, then read nothing but the resource ID and address prefixes. On a large tenant that is a subnet mv-expand of up to 1,024 rows per virtual network, two peering joins, one ARM call per virtual hub and two Cosmos DB queries — on every request — and a direct contributor to the Resource Graph throttling that surfaces as failed allocations. A prefix-only query now backs the thirteen call sites that need nothing further. Most expand responses still use the detailed query because they return whole network objects, but the available-networks endpoint is an exception: its expanded response is modelled on NetworkExpand, whose six fields are exactly what the prefix-only query already returns, so every additional field the detailed query produced was discarded field-for-field. That endpoint backs the network association picker in the UI, which always requests expansion, so the most frequently exercised expanded path now costs one Resource Graph query rather than two. Nothing is cached: results are still read live on every request, so allocation decisions remain based on the current state of Azure. Verified against a live tenant of 158 networks as returning an identical network set, identical prefixes and an identical total address count, with utilization responses byte-for-byte unchanged
  • Azure Resource Graph failures are now classified instead of reported as access denied: Every non-success response from Resource Graph was caught as a bare HttpResponseError and re-raised as 403 Access denied, so throttling, upstream outages and expired credentials all presented as authorization failures — sending users to audit permissions for a fault that had nothing to do with them. Because ClientAuthenticationError subclasses HttpResponseError, the Token has expired handlers in the three Resource Graph wrappers were unreachable. Failures are now classified at a single choke point: throttling returns 429 with Retry-After, upstream and unexpected statuses return 502 rather than being blamed on the caller, authentication failures return 401 — or 500 when the Azure IPAM service principal itself failed to authenticate — and a genuine Forbidden still returns 403. The HTTP exception handler now propagates response headers so Retry-After reaches the caller. Percent-style placeholders in loguru calls, which silently dropped their arguments, were converted at the same time
  • Data Factory discovery now honours subscription exclusions: Data Factory lookup built its own Resource Graph client around a hardcoded query instead of going through the shared query path, so it never applied the configured subscription exclusions and returned factories from subscriptions an administrator had deliberately excluded. It now uses the shared path with a DATA_FACTORY query carrying the standard exclusion filter, which also removes a duplicated Resource Graph client and its per-call setup
  • Virtual Machine Scale Set discovery now skips excluded subscriptions: Scale Set discovery runs through the Azure SDK rather than Resource Graph, and enumerated every subscription visible to the credentials — so excluded subscriptions were still queried, returning resources the administrator had excluded and spending an SDK call per subscription to do it. Subscription enumeration now accepts the exclusion list and filters before any Scale Set call is made
  • Scale Set addresses no longer fail on the sovereign cloud discovery path: The Scale Set endpoint expanded each record over a private_ips array, which is the shape Resource Graph returns. The SDK path used where Resource Graph coverage is incomplete — sovereign clouds such as IL6 — returns a singular private_ip, and those records raised a KeyError instead of being returned. Both shapes are now handled
  • vWAN transit network matching no longer depends on a regular expression: When a Virtual Network connects to a vWAN hub, Azure peers it to a generated transit network named HV_<hub>_<suffix>, which Azure IPAM substitutes back to the hub itself for display. The hub name was interpolated into a pattern without escaping, and Azure permits periods in hub names — so a hub named my.hub produced a pattern in which the period matched any character, and myXhub would have matched too. The pattern was also case-sensitive against a Resource Graph identifier. The transit network is now matched as a lowercased substring, which is all the comparison ever needed and removes both problems
  • Azure resource IDs are now compared case-insensitively: Azure returns resource IDs with inconsistent casing — Microsoft documents this and directs callers to "always perform a case-insensitive comparison of names" — and Azure IPAM stores whichever casing the client supplied when a network was associated, since association validates case-insensitively but saves the request body verbatim. Fifteen comparisons matched those two values exactly, so a casing difference made a managed network invisible: utilization under-reported the address space in use, Block CIDR and external network validation skipped the network entirely, reconciliation marked it inactive, and the CIDR check reported it as belonging to no Space or Block. Every one of these failed in the same direction — occupied space reading as free, which is the failure mode an IPAM can least afford. One of them compared a Resource Graph ID against an ARM SDK ID, where the two APIs genuinely disagree on casing. Reservation identifiers, which Azure IPAM generates itself, remain case-sensitive
  • A Virtual Network that fell out of its Block can no longer be locked out of rejoining it: Reconciliation marks an associated network inactive when it is absent from the Resource Graph network list, which is how a deleted network is recognized. Two faults above caused that to happen to networks that were present — an exact-match comparison against a stored ID whose casing differed, and a truncated Resource Graph result being treated as complete — and both now leave the network active. The reported symptom, however, had a second half that neither fix reaches: re-associating the network was then refused with Block already contains network(s) and/or reservation(s) within the CIDR range of target network, leaving an administrator to delete the entry from Cosmos DB by hand. POST /api/spaces/{space}/blocks/{block}/networks rejects a network already associated to the Block, but that was the one comparison in the handler the case-insensitivity sweep did not reach, so a stored ID differing only in casing slipped past it — and was caught instead by the overlap check below, which does resolve IDs case-insensitively and so found the network overlapping its own stale entry. The error was describing a network as overlapping itself. That check is now case-insensitive, and the condition is reported as what it is. The Azure IPAM interface was never affected, as it associates networks through PUT .../networks, which replaces the list rather than appending to it (fixes vNets in Block becoming unassociated and cant be associated again #329)
  • Scale Set discovery no longer depends on resource ID casing: Six expressions pulled the resource group, virtual network, subnet and instance number out of ARM resource IDs by matching the resourceGroups/, providers/, virtualNetworks/, subnets/ and virtualMachines/ segments literally. Unlike the comparison bugs above these failed loudly rather than silently — a non-matching expression returned no match and the immediate .group(0) raised, so the whole Scale Set request became a 500 — but the trigger is the same casing inconsistency, and the same resource can be returned with different casing by different Azure APIs. This is the SDK-based Scale Set path, which runs only outside Azure Public; commercial deployments answer the same request from Resource Graph and were never affected. That makes it a sovereign and air-gapped cloud fix, which is also where Azure IPAM depends on the SDK paths most, because Resource Graph coverage there is incomplete. The segment matches are now case-insensitive, which is what the equivalent expression in the Reservation path already did
  • Block utilization is computed in one place: The arithmetic that populates size and used on a Block, its networks and their subnets was duplicated across the four endpoints that report utilization — roughly thirty lines repeated four times, differing only in local variable names and in whether the running Space totals were accumulated alongside. That duplication is what allowed the Space utilization defect above to exist in exactly one of the four copies. The four copies collapse to a single add_block_utilization helper, with the Space totals now summed from each Block's own figures, which is arithmetically the same value since the Space totals were only ever the sum of their Blocks. Around seventy lines are removed. The behaviour is deliberately unchanged, including two long-standing quirks in the per-network figures that are documented and fixed separately, and equivalence was verified both against the original logic across every network shape — fully in-Block, partially in-Block, entirely outside the Block, virtual hubs, networks without subnets, unmatched associations and empty Blocks — and byte-for-byte against a live tenant across all twenty-eight utilization responses
  • External networks now report utilization the way Azure networks do: A Block counted each external network's full address range toward its own utilization but reported nothing about how that range was allocated internally, even though external subnets and their endpoints were added to the data model later. External networks and their subnets now carry size and used alongside the Azure networks they sit beside. An external network's used is the address space assigned to its subnets, mirroring how a Virtual Network's used is the space assigned to its subnets; an external subnet's used is the number of endpoints defined within it, mirroring the consumed addresses reported for an Azure subnet. Endpoints are counted as they are, without the five addresses Azure reserves in every subnet, because an external network is not Azure and reserves nothing. The Block's own used is deliberately unchanged and still counts each external network's full range exactly once, since its subnets sit inside that range and counting both would double count. This required utilization variants of the external network and subnet models, so the utilization responses gain two fields at each level and lose none
  • A network's utilization within a Block is measured on one basis: For an expanded network, size counted only the prefixes falling inside the Block while used counted the subnets of every prefix the network owns, including those outside it. The two figures answered different questions and were presented as a ratio, so a network whose address space straddles a Block boundary could report more space used than it has — on the verification tenant one reported size 256 against used 384. Separately, used was reset inside the loop over in-Block prefixes, so a network with no prefixes inside the Block never had it reset at all and reported the entire network's subnet total against a size of zero. used is now initialized once and counts only subnets that fall inside the Block, so both figures describe the same address space and used can no longer exceed size. Block and Space totals are unaffected because they never consulted subnets, and each subnet still reports its own size regardless of where it sits. The Azure IPAM interface never requested these figures, since it does not expand Spaces or Blocks, so this corrects the documented API for automation consumers rather than anything visible in the product
  • Unreadable Reservation tags are reported rather than silently ignored: The helper that splits a Reservation tag into IDs wrapped its work in a bare except returning an empty list, which conflated two unrelated situations — a network carrying no tag at all, which is the normal case for nearly every network, and a tag whose value could not be read. The Resource Graph query parses tag values as JSON so that an absent tag resolves to null, with the side effect that a value resembling JSON arrives as a list, object or number instead of text. Such a network was then treated as having no Reservation, so the Reservation waited to be fulfilled indefinitely and nothing was written to the log. The type check is now explicit, and reconciliation reports the network ID and the offending value once per run rather than once per comparison. Behaviour is otherwise unchanged and was verified identical across every input type the tag can produce: unreadable values are still skipped rather than guessed at, and no attempt is made to strip quotes or interpret JSON, since Resource Graph already removes surrounding double quotes and interpreting the rest would mean acting on a tag the user did not write as an ID
  • Azure Resource Graph throttling is now retried by the SDK rather than failing outright: Resource Graph reports throttling on a POST and states the wait in x-ms-user-quota-resets-after instead of the standard Retry-After. The Azure SDK's retry policy therefore never retried it — POST sits outside its method allowlist, and the Retry-After short circuit that would have overridden that never fires — so a throttled query failed on its first attempt even though 429 is in the SDK's own retryable set. The SDK's policy is now taught both facts rather than a second retry loop running alongside it, so it waits exactly the interval Resource Graph reported instead of backing off blindly, and a hand-picked backoff ceiling gives way to the SDK's own settings. One retry setting is deliberately overridden: Resource Graph quota is shared by every caller using the same credentials rather than allocated per user, and replenishes roughly every five seconds, so the SDK's default of three status retries could be spent in about fifteen seconds while a burst of requests was still draining. That allowance is raised to six, which bounds a throttled request at roughly thirty seconds of waiting rather than a failure. It is not raised further on purpose — beyond ten the total retry budget silently becomes the real limit, and a longer wait encourages users to reload the page, which adds load to the very quota the request is waiting on
  • Truncated Resource Graph results are no longer treated as complete: Resource Graph withholds the continuation token when it truncates a result set, so the missing rows cannot be paged for and the response is silently short while still reporting success. For an IPAM that is the most dangerous shape of failure, since an unseen network reads as free address space. The condition is now detected and reported as an error rather than returned as though whole
  • Space utilization no longer fails when a Block holds an external network: GET /api/spaces/{space}?utilization=true returned 500 for any Space with an external network in one of its Blocks. External address space was accumulated onto the space path parameter — a string — rather than the Space document, raising TypeError: string indices must be integers. The line was correct in the equivalent list handler, where space is the loop variable, and was copied into the single-Space handler without renaming. The list endpoint and the Block-level endpoints were never affected, which is why the fault went unseen
  • Virtual hubs now expand in Space and Block responses: Requesting expand=true on a Space or Block containing a vWAN hub silently returned unexpanded network references — just an ID and active flag — with an HTTP 200 and no error. The expanded network model requires a subnets field, which virtual networks carry and virtual hubs never had, so validation of the expanded shape failed and the Union response model quietly fell through to the plain reference shape. The effect was not limited to the hub: a single hub in a Block collapsed every network in that Block back to a reference. Virtual hubs now carry an empty subnet list, which is what they genuinely have, so the expanded shape validates. The Block networks and available-networks endpoints were never affected, as their response model does not require subnets
  • Virtual hub listing no longer fails once a hub is associated to a Block: GET /api/azure/vhub returned 500 as soon as any vWAN hub was associated to a Block, reporting Input should be a valid string for parent_block. A network can belong to Blocks in more than one Space, so the engine has always produced a list of Block names here; the response model declared a single string. The model was corrected to match the data rather than the reverse, since collapsing to one name would discard a real association. The defect was invisible on /api/azure/network, which returns the same hub data but declares no response model, and on the virtual network equivalent for the same reason
  • A network's Blocks are now reported with the Space each one belongs to: A Virtual Network or vWAN Hub reported parent_space as a single value alongside parent_block as a list, but a Block is identified only by the pair of Space and Block name — Block names are unique within a Space, not across a tenant. A network associated to Blocks in two Spaces therefore returned both Block names against whichever Space happened to match first, and the second Space was unrecoverable from the response. Three consumers had each independently reconstructed the pairing, and each failed on that data. The Planner joins every Block name against the reported Space and skipped any Block it could not find, dropping the network from the view with nothing but a console warning. External Network validation collected the prefixes already present in a Block by the same join, so a network whose reported Space differed was skipped and its address space read as free — client-side only, as create_external_network repeats the check server-side and refused the request rather than writing an overlap. Block rename matched networks on Block name alone, so renaming a Block relabelled same-named Blocks in other Spaces. Both fields are replaced by parent_containers, a list of Space and Block pairs reusing the CIDRContainer model that POST /api/tools/cidrCheck already returns, which moves to the shared response models rather than being duplicated. An unassociated network now reports an empty list instead of a pair of nulls, which removes a null check from every consumer. The four comparisons that resolve a network to its Blocks were also still case-sensitive, so a stored ID whose casing differed from the one Azure returned made a managed network report no Space or Block at all; they now match the rest of the engine, and the Block list they search is built once per request rather than once per network, against a pre-lowered set. /api/azure/* is registered with include_in_schema = False and appears in no documentation, example or test, so this is not a change to a published contract
  • A network the Block will refuse is no longer offered as available: GET /api/spaces/{space}/blocks/{block}/available filtered a network's prefixes individually, keeping those inside the Block that overlapped nothing and discarding the rest, then offered the network on the strength of whatever survived. The association handlers do not work that way — POST and PUT .../networks evaluate every in-Block prefix together and refuse the whole network if any one of them overlaps an External Network or an unfulfilled Reservation. A Virtual Network with two prefixes inside a Block, one of them colliding, was therefore listed as available, displayed with the colliding prefix silently removed, and then rejected on save describing an overlap the user could not see anywhere in the interface, because the range responsible had been edited out of the very list offering the network. The endpoint now applies the same whole-network rule as the handlers, so what it returns can actually be associated. Since that makes an expected network vanish rather than fail, an include_blocked parameter returns those networks as well, each carrying a blocked_by entry naming the External Network or Reservation occupying each prefix; it requires expand, as the unexpanded response is an array of resource IDs with nowhere to carry a reason. The blocked shape is a separate NetworkExpandBlocked response model rather than a field on NetworkExpand, which is shared with the Block networks endpoint where a network is associated by definition and can never be blocked. Attribution is computed only for a network that is actually blocked, and only when the parameter is set, so the default response costs the same single set intersection per network that it did before (fixes Virtual networks not discoverable  #200)
  • A refused network list now names the entries that conflict: PUT /api/spaces/{space}/blocks/{block}/networks reported Network list contains CIDR(s) that overlap external networks without saying which of the submitted networks was responsible, leaving the caller to find it by inspection across a list that can run to dozens of entries. The three overlap checks tested a merged CIDR set built from every network in the request, and that set had already discarded the attribution by the time the comparison ran. Each check now compares per network and collects the offenders, which is the pattern the same handler already followed for invalid resource IDs and for networks outside the Block CIDR — two of its five errors named the entries at fault and three did not. The conditions under which each error is raised are unchanged, since a merged set intersects only where one of its members does, as is the order in which they are reported
  • The Swagger UI can authenticate again: Every protected endpoint declared its token as a header parameter named authorization, carrying a description intended to render an input box in the interactive documentation. The OpenAPI specification requires that a header parameter named Authorization be ignored, and swagger-client implements that literally: it rendered the box, then discarded the value when building the request. The result was the worst available shape — a field that looks like it works, silently drops what is typed into it, and returns Authorization header is missing. The document declared no security scheme at all, so the Authorize button could not help either: of 58 documented operations, none carried a security requirement and 55 declared the parameter that is thrown away. The token is now advertised as an HTTPBearer security scheme, which is the one place the specification reserves for it, so Authorize attaches the header and the generated cURL includes it. The scheme is declared as a sub-dependency of validate_token rather than as a separate router dependency, so an operation cannot advertise authentication without also enforcing it — the reverse arrangement would let a router display a padlock while accepting every request. The 120-line IPAMToken class commented out in dependencies.py since the framework commit was removed rather than revived: it called its own async validator without awaiting it, so every token passed; it called check_admin as a bare name from inside a method, where it is not in scope; it read a module-level _session it never assigned; and it fetched signing keys with requests, which validates TLS against the bundled certifi roots and is banned for that reason (fixes IP reservation from Swagger API UI errors with "Authorization header missing" #99)
  • Admin restrictions are declared once and documented from the declaration: Thirty-eight endpoints enforced their admin requirement with an inline if not is_admin: raise HTTPException(403), and nothing about that requirement appeared in the API documentation — a caller could not tell which operations needed admin without reading the source. Annotating a 403 response by hand on each one was considered and rejected, as it leaves forty-six pieces of metadata sitting next to, but not derived from, the check they describe; the first guard added without its annotation publishes a contract that is wrong, and nothing catches it. The inline checks are replaced by a require_admin dependency, and the four endpoints where only the expand parameter is restricted by an admin_expand gate that declares the parameter itself. A generator in the new app/openapi.py walks each route's dependency tree and annotates the schema from what it finds: require_admin appends (Admin Only) to the operation summary and attaches a documented 403, while a parameter-level gate marks only the parameter it guards, since the endpoint itself is not restricted. Documentation and enforcement therefore cannot disagree. Marking is best effort and never fatal — it reads fastapi.routing.iter_route_contexts, which is not a public API, so a FastAPI upgrade could break it; the engine still serves a valid schema and logs the number of marked operations, and enforcement is unaffected either way because the gates are ordinary dependencies. Removing the inline checks also left thirty-seven is_admin parameters unused, which are dropped. Two wordings of the same message — API restricted to admins. used twenty-nine times and This API is admin restricted. used nine — collapse to one; the ownership checks that also return 403, such as refusing to delete another user's Reservation, are deliberately left inline, as they are not admin restrictions
  • Authentication results flow through the dependency graph rather than around it: validate_token wrote the tenant ID onto request.state and check_admin wrote the admin flag, which get_tenant_id and get_admin then read back. Nothing in the dependency graph expressed that ordering, so a route using either accessor without also declaring the auth chain raised AttributeError and returned a 500 where it should have returned a 401. The side-channel dates from the original multi-tenancy work, where the auth gate returned nothing and request.state was the only way to reach a route handler; that stopped being true once the gate became a value-returning dependency. The accessors now depend on the function that produces what they need, and FastAPI's per-request caching means token validation and the Cosmos admin lookup each still run exactly once. No router changes were required, as every call site already went through Depends(get_admin) or Depends(get_tenant_id)
  • The bearer token is passed instead of the raw header: Routes took the full Authorization header and nineteen call sites across the routers and their helpers immediately stripped the Bearer prefix with authorization.split(' ')[1]. The header value was used for nothing else anywhere in the engine. Routes now depend on get_token_auth_header, which already existed and already parsed the header correctly, and pass the token itself down to the helpers. The inline parsing split on a literal single space, so a tab or a doubled space yielded an empty token or an uncaught IndexError, and a Basic header would have had its credential extracted; those paths were unreachable only because validation rejects malformed headers first. Parsing now happens in exactly one place

UI & UX Improvements

  • Drill-down navigation in Discover (fixes Navigation Improvements #183): Added hierarchical drill-down with multi-filter pass-through and hidden column auto-reveal when filters are active
  • Discover filters now honour the operator they are given: Every drill-down and every search bar selection navigates with a filter descriptor that toAgGridFilter converts into an AG Grid filter model, and that conversion never read the operator the caller set — it hardcoded AG Grid's contains, so every text filter was a substring match. Drill-down therefore returned more than it was asked for: a Block named Prod returned the networks of Production alongside its own, and the same held for a Space, Virtual Network or subnet whose name is a prefix of another. Nothing was mis-reported, but the row set silently included rows belonging to a sibling. The pre-AG Grid implementation merged the whole descriptor onto its own filter object, so the operator was honoured without a mapping and none was ever written; the port rebuilt the descriptor field by field and dropped it. The operator is now mapped to an AG Grid filter type and drill-down asks for equals, since it wants the children of one named parent, while the search bar continues to send contains where a substring match is the intent — its like wording is display text for the dropdown and was never an operator. Exact matching also required an array-aware matcher for the Block column, whose filter value is its elements joined into a single string, so equals against BlockA, BlockB could never have matched a network associated to both; the unused arrayTextFilterComparator is replaced by arrayTextMatcher, which compares each element and honours the filter option in use. Drill-down still identified a parent by name alone at that point, so a Block, Virtual Network or subnet sharing a name with one under a different parent still returned both; that is addressed in the entry below
  • A drill-down now identifies its parent rather than naming it: A drill-down navigated to the child tab with a filter on the parent's name, but a name identifies a parent only where it cannot be reused — which is true of a Space and of nothing else. A Block name is unique only within its Space, a Virtual Network name only within its resource group and subscription, and a subnet name only within its Virtual Network. Drilling into a Block therefore returned the networks of every Block sharing that name in every Space, and drilling into a Virtual Network returned the subnets of every Virtual Network sharing that name. The multi-field form the filter descriptor already supported — used by the subnet to endpoint drill-down and nowhere else — is now used by every drill-down: a Block is identified by its Space and its name, a Virtual Network by its name, resource group and subscription. The Space a network belongs to is shown on the Virtual Network and vWAN Hub grids so the Block filter has a visible column to act on, and so a network's Space can be read without opening its details. The icon deciding whether a row has any children was keyed on names too, matching a Set of parent names per child collection, so every subnet named default offered a drill-down into another subnet's endpoints, and a Block offered one because a same-named Block elsewhere had networks. The six name-keyed selectors are replaced by one selectDrillIndex keyed on identity — the id a Block already carries, and the ARM resource IDs the engine already returns, compared without regard to casing — which also retires a renderer hook that could read only two drill-down targets and silently ignored a third. A filter model resolving to no entries was truthy and cleared every filter, so a drill-down with nothing to match on read as one into the whole table; it now applies no filter at all. The two drill-downs into Endpoints stay on names, as an Endpoint records its own resource group and subscription rather than its parent network's, so neither can narrow the match and the only exact key is a resource ID that has no place in a filter box. A network in Blocks across several Spaces can likewise satisfy the Space of one Block and the name of another and appear under a Block it does not belong to; pairing them requires a blended column existing only to serve this filter, so the extra rows are accepted and the reasoning is recorded where the filter is declared
  • An association is now reflected without waiting for a refresh: Associating a network to a Block recorded the network on the Block, but the grids read the other side of that relationship — the parent containers the engine derives by cross referencing the Space documents — which nothing updated. A freshly associated network therefore showed as <Unassigned> in Discover, and its Block offered no drill-down, until the next refresh re-fetched the networks. The association is now mirrored onto the network in the same reducer, following the pattern a Space or Block rename already uses, so no further call is made to the API and the periodic refresh reconciles the result either way. Only the named Block is rewritten, leaving a Block of the same name in another Space untouched. A Block's identity, which pairs its name with its Space, was also stamped only when Spaces were fetched: creating a Block never set it, and renaming a Block or its Space left it behind, so the grid tracked rows by an identity that no longer described them. It is now derived in one place and reapplied wherever either name changes. Block and Space utilization still come from the engine and settle on the next refresh
  • Centralized authentication handling: New AuthHandler for MSAL error handling, centralized token acquisition via tokenService
  • Custom DraggablePaper component: Replaced react-draggable package with a purpose-built component
  • Associations UX: Shifted API work to Redux thunks, optimized data refresh, fixed infinite update loops by separating initial grid selection from user selection state
  • Planner data loading: Addressed issue where Planner would not load when data was incomplete (fixes Planner will not load #366)
  • Reservation UX: Fixed view reversion after cancelling a Reservation, fixed column sorting, and streamlined the interface
  • Unified grid experience: Centralized DataGrid and ConfigureGrid components with shared filter utilities, consistent loading overlays, and AG Grid custom styling (brightness filter for row hover)
  • Search bar: Fixed messaging when no resources are found in Azure IPAM scope
  • API errors without the standard envelope are now surfaced: The Axios response interceptor read error.response.data.error unconditionally, so any response that did not use the { error } envelope produced an Error with an empty message — and an error snackbar with no text at all. Unhandled engine exceptions return plain text, request validation failures return { detail }, and proxy errors return HTML; each is now resolved to a message, falling back to the Axios status text. This also fixes silent failures on any request rejected by model validation, which had always returned 422
  • The associations grid now accounts for a network it cannot offer: The grid listed only the networks a Block would accept, so a network absent from it was indistinguishable from one that did not exist — the symptom reported in Virtual networks not discoverable  #200. Blocked networks are now requested from the engine and rendered with an amber background, with a tooltip on every cell of the row naming the prefix at fault and the External Network or Reservation occupying it, one such range per line. They are rendered without a checkbox, since the association would be refused; the exception is a network that became blocked after it was associated, for example because a prefix was added to it in Azure that collides with an External Network, which stays selectable so that the conflict can be cleared from the same screen. Because a Block carved up with External Networks can produce a long list of them — 43 of the 117 networks in one Block of the verification tenant, from a single External Network and three Reservations — the grid menu gains a Showing Available / Showing All toggle following the pattern already used for settled Reservations, defaulted to hiding them; a blocked network that is still associated is never hidden, as it is a state the administrator has to act on and a hidden row would still be submitted on save. An Error and a Blocked count appear beside the selection counter whenever either is present, each carrying a swatch in the colour of the rows it describes so the tints are read rather than learned, and the blocked count reports how many of those networks the filter is actually hiding, which is lower than the total whenever one of them is still associated. The row tints and the selected row colour are declared once and consumed by both the grid styling and the legend, so the two cannot drift apart. Save is now refused while the selection holds a network the engine will reject, with the button tooltip naming it: saving replaces the Block's entire network list, so a single ineligible entry would otherwise take any unrelated change down with it. That last point also covers stale associations, which are pre-selected when the page loads and produced exactly the same rejection, and which now carry an explanatory tooltip of their own rather than an unexplained ErrNotFound. Three AG Grid defaults had to be corrected before any of this was legible: a two second tooltip delay which reads as no tooltip rather than a slow one, a tooltip element which collapses newlines so that several ranges ran together on one line, and a disabled checkbox which still takes focus on a row that can never be selected
  • Planner subnet tiles are now sized to their content rather than to the row: The tile grid sized each subnet as a fraction of a twelve column MUI Grid, which divides the row by position and knows nothing of what it holds. An xxl={1} rule packed twelve tiles per row at 1920px and left 141px for a CIDR needing 150px, so 10.183.119.224/31 rendered as 10.183.119.224/… — the mask being the one part that distinguishes a tile from its neighbour. The font clamp had already been walked down from 20px to 16px to 15px with an ellipsis added to absorb the overflow, each change moving the width at which the text stopped fitting rather than removing it, since no fraction fits content at every viewport. The tiles are now laid out with CSS Grid auto-fill, declaring a minimum tile width and letting the browser choose the column count, so the constraint runs from the content outward and no viewport can produce a column too narrow for the longest possible CIDR; verified from 320px to 3840px in 20px steps against 255.255.255.255/32. The minimum is expressed in pixels rather than rem to stay in step with the px font clamp, as a rem minimum shrank with a reduced browser root font size while the text did not, reintroducing the same truncation at 14px and below. plannerTheme is removed with it, having existed only to supply the xxl breakpoint the grid no longer uses

Notifications & Service Management

  • New notification framework (GET /api/notifications, POST /api/notifications/{id}/resolve): A self-describing, API-first advisory system. Server-side detectors emit notifications that the UI — or any API / IaC consumer — can read, while remediation is resolve-by-reference: the client asks the backend to resolve a notification by id and the backend owns all the logic (admin-gated, with an active-notification guard). Adding a new advisory is a single detector module registered in one list
  • Registry-migration detector: Flags deployments still pulling container images from the legacy public registry (azureipam.azurecr.io, critical) or the development registry (azureipamdev.azurecr.io, warning) and offers a one-click remediation that repoints the App Service / Function LinuxFxVersion to registry.azureipam.com. The target image is validated as anonymously pullable before any change is applied, then the app restarts to pull it — complementing the update script's auto-migration with an in-app path
  • Update-available detector: Compares the running IPAM_VERSION against the latest published GitHub release and surfaces an informational notice linking to the update guide. Fails safe (no notification, no error) on network or rate-limit failures
  • Platform-migration detector: Flags deployments still running on the legacy Docker Compose App Service model (DEPLOYMENT_STACK == "LegacyCompose", critical) and links to the migration guide ahead of Microsoft's March 31, 2027 retirement of Docker Compose support for Azure App Service. Link-only guidance (no in-app remediation) since migration is a scripted, multi-step process run from the operator's workstation
  • Notification center (UI): AppBar bell with a severity-colored unread badge, a severity-ordered list (popover on desktop, full-screen sheet on mobile), a detail dialog with server-driven actions and early admin gating, per-item dismiss, mark-all-read, and a manual refresh that resyncs to the server's truth
  • Service restart gate (UI): A full-screen overlay shown while the backend restarts (e.g. after a registry switch). The live SPA stays in memory, polls /api/status, and confirms recovery via a changed service start time before reloading — with calm, time-keyed messaging and a deliberate manual-reload escape hatch if recovery runs long. Background polling is paused while the gate is up

Deployment, Build & Infrastructure

  • Configuration drift detection and remediation in update: The update script now compares an existing deployment against what a fresh deployment would produce today, presents the differences, and converges them on approval. Detection is based on live Azure resource state rather than the version originally deployed, so each difference is evaluated independently and a partially-current deployment only sees what it actually needs. Covers the container registry endpoint, Python runtime version, App Service startup command, health check, baseline app settings, creation of the staging slot, and Function App slot-sticky content-share settings. Existing values and user-added settings are never overwritten, and removal is restricted to settings Azure IPAM owns that no longer apply to the deployment's shape. Production is converged before the staging slot so a newly created slot inherits corrected values, and the two are kept in sync thereafter so a future swap cannot regress production. When an archive is supplied explicitly, the Python runtime version is read from that archive rather than from the local checkout, so LinuxFxVersion is retargeted to match the wheels actually bundled in it — those wheels are ABI-specific, and a mismatch leaves every native module unimportable at startup
  • Version-aware update decisions: Version comparisons across all three update paths (public registry image label, private ACR repository build, and native GitHub release) now use semantic versioning rather than string equality, so prerelease suffixes order correctly. Downgrades are no longer silently applied and reported as updates — the update stops and points to -Force. Images built without a version stamp report the 0.0.0 Dockerfile default and are treated as an unknown version rather than a downgrade
  • Updates against stopped or unreachable deployments: The update script now detects application state up front and reports it. Configuration changes, image builds, and ZIP deployments all continue to work, restarts that would achieve nothing are skipped, and a stopped application is reported as remaining stopped. A new -ContainerType (Debian|RHEL) override on update matches the existing migrate switch, so a private ACR deployment can still be rebuilt when its container distro can't be probed. The distro probe is also now bounded by a timeout, so an application whose container fails to start no longer stalls the update indefinitely
  • Guidance instead of failures for anticipated conditions: update and migrate no longer raise exceptions for situations with a known remedy, such as an undetectable container distro. These now print actionable guidance under the relevant phase heading and exit cleanly, matching how legacy Compose deployments and out-of-resource-group registries were already handled. Genuinely unexpected errors now surface their message on screen rather than only in the log
  • DOCKER_REGISTRY_SERVER_URL removed from all deployment templates: This setting is only required for registries authenticating with stored credentials. Azure IPAM pulls anonymously from the public registry or with a managed identity from a private ACR, so it was never needed, and App Service removes it on its own whenever the container registry is reconfigured — which caused the update script to repeatedly report it as configuration drift
  • Azure PowerShell requirements aligned to 11.4.0: The deployment, update, and migration guides now state a single Azure PowerShell rollup version, and each script's granular #Requires module pins match the versions packaged in that rollup. Previously the update guide understated its requirement (Az.Resources 6.16.0 is only available from Az 11.4.0), and deploy.ps1 pinned the Az 10.3.0 module set while its documentation stated Az 11.0.0
  • Fixed Debian container build port: update.ps1 built Debian images with PORT=80 while deploy.ps1, migrate.ps1, and the Dockerfile default all use 8080
  • Public container registry migrated: The publicly hosted Azure IPAM container images have moved from azureipam.azurecr.io to the new registry.azureipam.com endpoint. The deployment and migration Bicep templates now reference the new registry by default, while the update script continues to recognize the legacy azureipam.azurecr.io endpoint so existing deployments keep working. The Docker Compose migration tooling intentionally still targets the legacy endpoint, as it only ever processes pre-existing legacy deployments
  • Migration robustness: The Docker Compose migrate script now halts on a non-standard or unresolvable container registry with guidance to re-run using the -JsonFile override, instead of silently falling back to the public registry. A new -ContainerType (Debian|RHEL) override handles cases where the source app is stopped/unreachable and its distro can't be auto-detected
  • RHEL images updated to UBI9: Including the container-image overrides in the deploy, update, and migrate PowerShell scripts (previously still pinned to UBI8)
  • Node.js minimum raised to 22.22.0: Required by React Router v8; enforced in the build.ps1 version gate and the UI package.json engines field
  • Dockerfiles optimized: Improved layering, removed unneeded steps across all container images. Resolved Hadolint lint violations (ADDCOPY, JSON notation for CMD/ENTRYPOINT, pipefail for piped RUN commands). Added centralized .hadolint.yaml for rule suppressions
  • KeyVault Soft Delete added to deployment and migration Bicep templates: enableSoftDelete and enablePurgeProtection are now set on the Key Vault resource. Without them, deployment is rejected outright under the Azure Landing Zone Enforce recommended guardrails for Azure Key Vault policy set (fixes Enable soft delete for Key Vault #373)
  • Build flexibility: Added support for building with either current or latest NPM/Python packages
  • CI/CD path exclusions: Added exclusions to avoid unnecessary test/build runs for documentation-only changes
  • GitHub Actions modernized: Bumped all workflow actions to their latest versions (checkout@v7, setup-node@v7, setup-python@v7, github-script@v9, create-github-app-token@v3, azure/login@v3, hadolint-action@v3.5.0), initially to clear the Node.js 20 runner deprecation and then to the current majors so the release does not ship already behind. The v7 line of the actions/* set is an ESM migration on the same node24 runtime, and none of the inputs these workflows pass were changed or removed. The fork-PR checkout restriction added in checkout@v7 does not apply here, as no workflow uses pull_request_target or workflow_run
  • GitHub Actions pinned to commit SHAs: Every action reference across all four workflows is now pinned to a full-length commit SHA with a trailing version comment, rather than a mutable tag such as @v7. A tag can be retargeted to arbitrary code by anyone able to push to the action's repository — the mechanism behind the tj-actions/changed-files and codfish/semantic-release-action compromises — and every one of these workflows holds credentials, whether an OIDC federated identity or a GitHub App private key. A new .github/dependabot.yml tracks the github-actions ecosystem weekly as a single grouped PR, with a seven day cooldown so a compromised release has a window to be reported before it is proposed here. Dependabot updates the SHA and the version comment together, so the pins do not go stale. Verified with the GitHub REST API that each pinned SHA is the commit the corresponding release tag resolves to
  • Azure PowerShell SDK v14 compatibility: Fixed Get-AzAccessToken breaking changes (fixes Breaking changes to Get-AzAccessToken #343); updated deploy & migrate scripts
  • OCI version labels on container images: All production images now carry standard OCI labels (org.opencontainers.image.version, .title, .source), stamped at build time via a new IPAM_VERSION build arg. This lets you determine the exact version behind a floating tag like latest by inspecting the registry — no pull or run required (e.g. docker buildx imagetools inspect or az acr manifest show). Covers all deb, rhel, and func variants across the root, engine, ui, and lb images, with the build workflow passing --build-arg IPAM_VERSION to every az acr build. Labels begin with the v4.0.0 release images
  • Release tag parsing hardened: Version extraction now strips only a leading v (^v) from the release tag, preserving suffixes such as -preview
  • Internet-restricted cloud deployments fixed: The ZIP-deploy path used by internet-restricted clouds (AZURE_US_GOV_SECRET) never resolved its bundled Python packages. init.sh referenced an APP_PATH variable that is defined nowhere in the repository — App Service exposes it only to interactive SSH sessions via ~/.bashrc, which a non-interactive startup command never reads — so PYTHONPATH expanded to a non-existent path at the filesystem root and no bundled dependency could be imported. The application root is now derived from the script's own location, matching what function_app.py already did for the Function App entry point. The accompanying PATH export was removed: it pointed at packages while pip installs console scripts to packages/bin, and nothing invokes them
  • Deployment archive now targets the App Service runtime rather than the build host: pip install --target resolves wheels for the machine running pip. When the GitHub Actions runner moved to Ubuntu 24.04, cryptography began resolving to a manylinux_2_34 wheel that cannot load on the App Service Python 3.11 image (Debian bullseye, glibc 2.31), producing GLIBC_2.33 not found at startup. Wheel resolution is now pinned explicitly (--only-binary=:all:, --platform manylinux2014_x86_64, --implementation cp, --python-version, --abi), with the Python tag and ABI derived from engine/app/version.json. manylinux2014 (glibc 2.17) is targeted deliberately, since sovereign clouds can run older stamps than commercial Azure
  • Native module verification gate: The build now fails if any bundled shared object targets the wrong Python ABI, requires a newer glibc than the platform tag allows, or is a Windows .pyd. Both defects above were invisible at build time and only surfaced after deployment — and the ABI mismatch presented as ModuleNotFoundError, which reads like a missing dependency rather than a build fault. The gate also inspects each distribution's *.dist-info/WHEEL metadata and rejects any wheel whose platform tag exceeds the target, so a locally compiled linux_x86_64 build or an over-new manylinux_2_28/manylinux_2_34 variant is caught by name before the binary scan has to find it. The highest glibc symbol required by any bundled module is now reported on every build, not only on failure
  • Deployment archives identify themselves: Every archive now carries a build.json at its root recording the build timestamp, the app and Python versions, the pip platform, the glibc ceiling and the highest glibc actually required, the native module count, and the resolved wheel tag for every package. In an air-gapped cloud a build log cannot be copied out, so establishing which build is actually deployed previously meant inference from indirect evidence; it is now a single cat
  • Sovereign cloud root certificates now trusted: Secret cloud deployments (AZURE_US_GOV_SECRET, IL6) run against endpoints whose certificate chains are issued by that cloud's own roots, which are not present in the App Service image's default trust store. Every outbound TLS call the engine makes — Key Vault references, Cosmos DB, ARM, Microsoft Graph — therefore failed certificate validation. WEBSITES_INCLUDE_CLOUD_CERTS is now set for that cloud in the deployment, update, and migration templates, so the platform injects the cloud's root certificates. The update script's configuration drift detection also adds it to existing secret cloud deployments. This setting is necessary but was not sufficient on its own — see the TLS validation fix under Engine & Backend, without which the engine still could not validate tokens in that cloud. The setting is gated on the cloud rather than on the deployment shape, so it now also reaches container deployments in that cloud, which the previous nesting excluded
  • Managed identity role assignments no longer collide on redeploy: Deployments that pin resource names via -ResourceNames failed with RoleAssignmentUpdateNotPermittedTenant ID, application ID, principal ID, and scope are not allowed to be updated — whenever the managed identity had been deleted and recreated. Role assignment names are globally unique GUIDs, and the Contributor and Managed Identity Operator grants seeded theirs from the identity's resource ID, which survives a delete and recreate unchanged. The name therefore matched an existing assignment while the principal behind it had changed, which Azure treats as an illegal update rather than a create. Every other module already seeded from the principal ID and was never exposed; managedIdentity.bicep could not, because a role assignment name must resolve at the start of deployment (BCP120) and the principal ID does not exist until the identity is created. Both grants moved into a new managedIdentityRoles.bicep module that receives the principal ID as a parameter, following Microsoft's documented guid(scope, principalId, roleDefinitionId) pattern — a recreated identity now yields a new assignment name and deploys cleanly, with no manual cleanup. Because the names change, a first re-run of deploy.ps1 against an intact deployment created by an earlier version may report RoleAssignmentExists; removing the two superseded assignments clears it. Deployments that let Azure IPAM generate resource names are unaffected either way, as every run produces fresh names. update and migrate needed no equivalent change — update deploys no role assignments at all, and migrate references the identity as existing and already seeds from the principal ID
  • Build script failure reporting: Seven prerequisite checks ended in a bare exit, which returns 0. A build that could not find NodeJS, or that rejected the installed Python version, printed errors and then reported success to CI. All now exit non-zero. The exception handler also printed an unassigned variable in place of the log path
  • Faster archive creation: Compress-Archive took 353 seconds to package the 6,404-file deploy archive; ZipFile.CreateFromDirectory produces an identically sized result in 11 seconds. The new API also preserves Unix file modes, which Compress-Archive flattened to 0644
  • CI Python version single-sourced: All three workflows now read the interpreter version from engine/app/version.json instead of pinning 3.11 by hand. This matters most in the versioning workflow, which regenerates requirements.lock.txt — dependency resolution is Python-version sensitive, so a lock file produced on the wrong interpreter can omit packages the runtime requires

Documentation Overhaul

  • Massively revamped How-To docs: authentication, exclusions, Discover, Reservations, External Networks, and Virtual Network Associations sections
  • New documentation: Comprehensive automation docs, API docs for vNet Associations, initial External Networks docs, detailed Reservations feature docs
  • 50+ screenshots added or replaced to reflect the current UI
  • Fixed all markdown warnings/errors and added .markdownlint.json configuration
  • Doc cleanup: Fixed stale links, grammar, spelling issues, deprecated folder descriptions, and undocumented switches across all sections
  • New troubleshooting entry: Deployments in internet-restricted clouds that report success without changing the running application — how to confirm which package is actually mounted, package retention and rollback, and the expected virtual-environment warning
  • Using the Swagger UI: New section in the API docs covering the Authorize button, obtaining and pasting a token, reading the generated cURL, and the fact that tokens are held only for the browser session
  • Documentation restructured around developing rather than contributing: The Contributing page was 77% development content — the Docker Compose environment and the container build instructions were filed under a page about CLAs and pull requests, which is also where two other pages deep-linked for them. It splits into three: Contributing for the process of getting a change accepted, Development for working on the code, and Conventions for the patterns to follow while doing so. The sidebar stays flat, as a nested entry suppresses the headings docsify generates for the parent page
  • New Conventions & Guardrails page: Records the places in the codebase where the obvious approach is the wrong one, each entry giving the rule, the reason the obvious alternative fails, and the failure mode to watch for. Seeded with three: how admin restrictions are declared and documented, why the API token is a security scheme rather than a header parameter, and why synchronous HTTP clients are banned in favour of aiohttp

Examples

  • Terraform example revamped: Migrated from Shell scripts to the official Azure IPAM Terraform provider
  • Azure ESLZ example modernized: Updated resource API references, modern coding standards, and improved parameterization
  • Script examples reorganized: PowerShell and Shell scripts moved into a dedicated examples/scripts/ folder with new helper scripts and README
  • Token helper function: Standardized access token generation; removed legacy Microsoft Graph SDK v1 support

Testing

  • Added tests for Virtual Network Association permutations
  • Expanded overall testing coverage with numerous additional Pester tests
  • Updated test expectations to align with additionally created resources
  • Fixed Pester test issues
  • Added Tools coverage for Block list evaluation — falling through to a later Block when an earlier one cannot satisfy the requested size (with and without smallest_cidr), and the genuinely exhausted case
  • CI lint gate: Added pre-deployment lint job to the testing workflow (ESLint, Vite build verification, Ruff for Python, PSScriptAnalyzer for the PowerShell scripts, Bicep template validation, Hadolint for Dockerfiles). Deploy is skipped if any check fails, preventing wasted Azure resources
  • PowerShell scripts are analyzed in CI: No PowerShell file had ever been linted automatically, despite the repository carrying a PSScriptAnalyzerSettings.psd1, which is how the test suite came to be the only script not following the house conventions and the only one carrying an analyzer finding. The lint job now runs PSScriptAnalyzer over every script and fails on a finding at any severity, and also fails if it matches no scripts at all, so the check cannot pass green having analyzed nothing. The step is deliberately scoped to the four rules the settings file configures: running PSUseCorrectCasing alongside the full default rule set trips a thread-safety defect in the analyzer's command cache and aborts the run roughly a third of the time, which would fail the build on a clean tree. That is PSScriptAnalyzer #1708, open since 2021 and reproducible in both 1.24.0 and 1.25.0, so pinning an older analyzer does not avoid it. The scoping is annotated in the workflow with the single change needed to undo it once the defect is fixed
  • PowerShell conventions applied uniformly: The Pester suite used capitalized Function and Param, making it the only script not following the lowercase keyword style the settings file describes and every other script applies, and it carried the repository's only analyzer error — a false positive from normalizing an Azure access token to a SecureString, now suppressed inline with the same justification already used in the deployment scripts. version.ps1 declared pipeline binding on all four parameters without a process block, so piping several objects would have silently processed only the last; nothing pipes to it, and no comparable script declares that binding, so the unused bindings were removed. Every script also declared $logPath and then referenced $logpath on the following line, a mismatch copied into all five. Every PowerShell file in the repository now reports no analyzer findings at any severity
  • Transient failure handling in the API test suite: The integration suite failed outright on the first transient error, so an Azure Resource Graph throttle during a run looked like a product regression rather than an environmental hiccup. The shared request helpers now retry only 429 and 5xx responses, honouring Retry-After; the client errors the suite deliberately asserts are never retried, and transport failures are never replayed, since a POST may already have applied server-side. Access tokens are cached and refreshed ahead of expiry, so a long run no longer fails partway through on an expired token
  • Utilization and network expansion coverage: Added a Utilization & Expansion context exercising utilization=true and expand=true across the Spaces, Space, Blocks and Block endpoints. Neither parameter had any coverage at all, which is precisely why the Space utilization defect above survived. The context asserts that the utilization reported without expansion matches the expansion path exactly — the two are answered by different Resource Graph queries, so this guards the prefix-only query against divergence; that a Block's used address count equals its networks plus its external networks; and that expanded responses genuinely carry the expanded fields, since a Union response model degrades silently rather than erroring. Every assertion was confirmed to fail against the unfixed engine before the corresponding fix landed, so none of them can pass vacuously. All are read-only, leaving the suite's ordered state untouched
  • External network and per-network utilization coverage: The utilization tests asserted Block and Space arithmetic but nothing verified a network's own size and used, nor that external networks reported utilization at all, so both defects fixed in this release would have passed the suite. Four assertions were added: that an expanded network never reports more space used than its size, that an external network's used is the space assigned to its subnets, that an external subnet's used is its endpoint count, and that a Block counts an external network once rather than counting its subnets again. The first three were confirmed to fail against the code that carried each defect; the fourth is a guard that would catch an external network being double counted if the accounting were ever changed

Bug Fixes

  • Fixes Document the terraform provider available to automate reservations. #85 — Documented the community Azure IPAM Terraform provider in the Automation docs
  • Fixes IP reservation from Swagger API UI errors with "Authorization header missing" #99 — The token box rendered against each endpoint in the Swagger UI was a header parameter named Authorization, which the OpenAPI specification requires be ignored, so the value typed into it was discarded and every call reported the header as missing. The token is now a bearer security scheme, attached through the Authorize button
  • Fixes Navigation Improvements #183 — Drill-down style navigation in the Discover section
  • Fixes Virtual networks not discoverable  #200 — A Virtual Network with several address spaces inside a Block was offered for association when only some of them were free, then refused when saved, because the available list evaluated prefixes individually while association evaluates the whole network
  • Fixes vNets in Block becoming unassociated and cant be associated again #329 — A Virtual Network became unassociated from its Block and was then refused on re-association, because the check for an existing association was the one comparison in that handler still sensitive to resource ID casing
  • Fixes feat: add subresource integrity #334 — Addressed PR concern
  • Fixes Breaking changes to Get-AzAccessToken #343Get-AzAccessToken breaking changes not accounted for
  • Fixes Assignment of multiple reservations from an address block on a single VNET #349 — Multiple Reservations fulfilled from the same Block against a single Virtual Network duplicated that network's Block association
  • Fixes Error fetching available IP Block networks #350 — Network associations not loading due to missing vNETs/vHUBs
  • Fixes Planner will not load #366 — Planner not loading when data isn't fully available
  • Fixes Standalone NICs not listed in Endpoint node #371 — Standalone NICs excluded from Endpoint list
  • Fixes Enable soft delete for Key Vault #373 — Deployment rejected with RequestDisallowedByPolicy where the Azure Landing Zone Key Vault guardrails are enforced, as the policy set requires soft delete
  • Fixes Overlap in CIDR #376 — Two Virtual Networks with overlapping address space could both be associated to the same Block, because Reservation overlap detection was a containment test and so missed a reserved range that enclosed an already-associated network
  • Fixes nextAvailableVNet fails when using multiple blocks and smallest_cidr option true if no IP range is available in first block #384nextAvailableVNet failed with an internal server error when using multiple Blocks with smallest_cidr and no range was available in the first Block
  • Fixed error snackbars appearing empty whenever the API returned a response that did not use the standard error envelope
  • Fixed Virtual Networks and vWAN Hubs associated to Blocks in more than one Space reporting every Block against a single Space, which caused the Planner to silently drop the network, misled the client-side address space check when adding an External Network, and let a Block rename relabel same-named Blocks in other Spaces
  • Fixed Discover drill-down returning the resources of similarly named siblings, as the filter it applied was always a substring match rather than the exact match it asked for
  • Fixed Discover drill-down returning the resources of a same-named parent in another Space, resource group or subscription, as a drill-down identified its parent by name alone where only a Space name is unique
  • Fixed the Discover drill-down icon appearing on rows with nothing beneath them, as it matched the names of parents that have children rather than their identities
  • Fixed a Block's row identity never being set when the Block was created, and going stale when the Block or its Space was renamed
  • Fixed a newly associated network showing as unassigned in Discover, with its Block offering no drill-down, until the next refresh
  • Fixed grid scrolling to top after saving changes
  • Fixed empty try/catch blocks and removed unused code/components
  • Fixed search bar message for empty results
  • Fixed input alignment and minor styling issues
  • Fixed Planner subnet tiles truncating the mask on wide displays, as each tile was sized as a fraction of the row rather than to fit the CIDR it carries

[major]

…ions

The suite used capitalized Function and Param, making it the only PowerShell
file in the repo not following the lowercase keyword style that
PSScriptAnalyzerSettings.psd1 describes and every other script applies.

It also carried the repo's only analyzer Error, from normalizing an Azure
access token to a SecureString. Suppress it inline with the same
justification already used in deploy.ps1, migrate.ps1 and update.ps1.

The suite now reports no analyzer findings at any severity.
version.ps1 declared ValueFromPipelineByPropertyName on all four parameters
without a process block, so piping several objects would have silently
processed only the last. Nothing pipes to the script, and no comparable
script in the repository declares pipeline binding -- update.ps1 has 81
parameters and migrate.ps1 41, both with none. deploy.ps1 is the only script
that does, and it implements begin and process blocks to match. Removing the
unused bindings leaves the parameter sets and every CI invocation unchanged.

version.ps1 also called get-date -format in lowercase, where every other
script uses Get-Date -Format, and carried a trailing space in its header
banner.

Separately, all five scripts declared $logPath and then referenced $logpath
on the very next line when creating the log directory. PowerShell variable
names are case-insensitive so the behaviour was correct, but the mismatch
had been copied into every script. Corrected in deploy.ps1, migrate.ps1,
update.ps1 and version.ps1; build.ps1 is handled separately.

No behavioural change.
The lint job covered the UI, engine, Bicep templates and Dockerfiles, but no
PowerShell file was ever analyzed, which is how the Pester suite drifted from
the repo conventions and accumulated the only analyzer finding in the tree.

The step is scoped to the four rules PSScriptAnalyzerSettings.psd1 configures.
Running PSUseCorrectCasing alongside the full default rule set intermittently
crashes the analyzer through a thread-safety defect in its command cache,
which would fail the step on a clean tree roughly a third of the time. That is
upstream PowerShell/PSScriptAnalyzer#1708, open since 2021 and present in both
1.24.0 and 1.25.0, so pinning an older version does not avoid it.

Verified in a clean Ubuntu container: the step passes on the current tree,
fails on reintroduced casing drift, and fails rather than passing vacuously
when no scripts are found.
…package

Add a wheel-tag layer to the native module gate in the build script. After pip
install, each *.dist-info/WHEEL is parsed and a distribution is rejected unless
one of its tags is `any` or a manylinux at or below the glibc ceiling derived
from PIP_PLATFORM. This catches locally compiled wheels (bare linux_x86_64) and
over-new ones (manylinux_2_28/2_34) by name, before the ELF symbol scan has to
find them. The gate is now three layers: ABI naming, wheel tag, GLIBC_ symbols.
The scan additionally records the highest glibc symbol required by any bundled
module and reports it on every build rather than only on failure.

Write build.json to the archive root, recording the build timestamp, app and
python versions, pip platform, glibc ceiling versus observed, native module
count and the resolved wheel tags per package. Air-gapped clouds cannot share
build logs, so the artifact has to be able to identify itself.

Resolve the engine's Python version from the archive being deployed rather than
the local checkout, and let configuration drift retarget LinuxFxVersion to
match. An archive built for one Python version could previously be deployed
onto a site configured for another with nothing detecting the mismatch; the
bundled wheels are ABI-specific, so every native module silently becomes
unimportable and the failure surfaces at startup rather than at deploy time.

Gate WEBSITES_INCLUDE_CLOUD_CERTS on the cloud rather than the deployment
shape, in the deployment modules, the staging slot modules and the update
script's target settings map. The trust store is a property of the cloud; the
run-from-package shape is not. The setting consequently now also reaches
container deployments in sovereign clouds, which the previous nesting excluded.

Add a hidden -RunFromPackage switch to the deployment and update scripts,
threaded through to a forceRunFromPackage template parameter, so the
internet-restricted deployment path can be exercised on clouds that would
otherwise build on the server. The switch is marked DontShow and is
intentionally undocumented.

Document deployments that report success without changing the running
application, covering how to confirm which package is actually mounted,
package retention, and the expected virtual environment warning.

Refs: GLIBC_2.33 import errors reported from IL6
…or emoji

The info, warning, and gear icons are written as a base character followed
by U+FE0F (variation selector-16). Those base code points have a neutral or
ambiguous East Asian Width, so the terminal advances one cell while the font
paints a two-cell emoji glyph. The icon overdrew the space that followed it
and messages rendered with the text jammed against the icon.

Pad all 48 U+FE0F sequences in update.ps1 and migrate.ps1 with a second
space so the separator survives the overdraw. Icons that carry the
Emoji_Presentation property are already measured as wide and keep a single
space, so they are left alone.

Also normalize the sole pair of curly quotes in the repository to straight
quotes in deploy.ps1. PowerShell's tokenizer accepts U+201C and U+201D as
string delimiters, so the debug flag lookup always resolved correctly; this
is a cosmetic change that leaves the file entirely ASCII.
Pre-v4 Reservation auto-fulfillment appended a Virtual Network to a Block's
association list without checking whether it was already present, so a
network carrying several Reservation IDs for the same Block was recorded
once per ID. Each surplus entry was counted again by the utilization
arithmetic, the removal endpoint deletes only the first matching index, and
re-adding was refused while any copy remained.

Reconciliation now reuses an existing association rather than appending, but
that prevents new duplicates without undoing the ones already written. The
v4 baseline convergence step collapses them, keyed on the lowercased
resource ID so copies differing only by Azure's inconsistent casing are
caught as well. The first occurrence wins, preserving both the stored
spelling and the list order, and 'active' is OR-merged so a duplicate whose
first copy was inactive does not read inactive until the next reconciliation
pass.

The step writes only where it finds a duplicate, so it is a no-op on a clean
database and safe to re-run, as the convergence module requires.

Affected deployments will see Block and Space utilization fall once it runs.
Each surplus entry was counting the same address space an additional time;
no address space is released.

Fixes #349
…to a Block

The existing-association guard in `create_block_net` compared the submitted
Azure resource ID against the stored IDs verbatim, and was the one comparison
in that handler the case-insensitivity sweep did not reach.

Azure returns resource IDs with inconsistent casing, and Azure IPAM stores
whichever casing the client supplied. A network already associated under
different casing therefore slipped past the guard and was caught instead by
the CIDR overlap check below, which does resolve IDs case-insensitively — so
the network was reported as overlapping its own stale entry, with the
misleading error "Block already contains network(s) and/or reservation(s)
within the CIDR range of target network". Deleting the entry from Cosmos DB
by hand was the only way to recover.

The comparison now lowercases both sides, matching every other ID comparison
in the handler, and the condition is reported as what it is.

Only POST /api/spaces/{space}/blocks/{block}/networks was affected; the
Azure IPAM interface associates networks through PUT .../networks, which
replaces the list rather than appending to it.

fixes #329
A Virtual Network or vWAN Hub reported `parent_space` as a single value
alongside `parent_block` as a list, but a Block is identified only by the
pair of Space and Block name, since Block names are unique within a Space
rather than across a tenant. A network associated to Blocks in two Spaces
returned both Block names against whichever Space happened to match first,
and the second Space was unrecoverable from the response.

Three consumers had each reconstructed the pairing independently, and each
failed on that data. The Planner joins every Block name against the reported
Space and skipped any Block it could not find, dropping the network from the
view with nothing but a console warning. External Network validation gathered
a Block's occupied prefixes through the same join, so a network whose reported
Space differed was skipped and its address space read as free — client-side
only, as `create_external_network` repeats the check and refused the request
rather than writing an overlap. Block rename matched on Block name alone, so
renaming a Block relabelled same-named Blocks in other Spaces.

Both fields are replaced by `parent_containers`, a list of Space and Block
pairs. It reuses the `CIDRContainer` model that `POST /api/tools/cidrCheck`
already returns, which moves to the shared response models rather than being
duplicated a second time. An unassociated network now reports an empty list
instead of a pair of nulls, removing a null check from every consumer.

The four comparisons that resolve a network to its Blocks were also still
case-sensitive, so a stored ID whose casing differed from the one Azure
returned made a managed network report no Space or Block at all. They now
match the rest of the engine, and the Block list they search is built once
per request rather than once per network, against a pre-lowered set.

`/api/azure/*` is registered with `include_in_schema = False` and appears in
no documentation, example or test, so no published contract changes. The
Discover grids and the search bar keep their existing shape, through
flattened projections derived in the Redux selectors.
Every Discover drill-down and every search bar selection navigates with a
filter descriptor, which `toAgGridFilter` converts into an AG Grid filter
model. That conversion never read the `operator` the caller set and
hardcoded AG Grid's `contains`, so every text filter was a substring match.

Drill-down therefore returned more than it was asked for. A Block named
`Prod` returned the networks of `Production` alongside its own, and the
same applies to a Space, a Virtual Network or a subnet whose name is a
prefix of another. Nothing was mis-reported, but the row set silently
included rows belonging to a sibling.

The pre-AG Grid implementation merged the whole descriptor onto its own
filter object, so `operator` was honoured without a mapping and none was
ever written. The port rebuilt the descriptor field by field and dropped it.

`operator` is now mapped to an AG Grid filter type, and drill-down asks for
`equals`, since it wants the children of one named parent. The search bar
continues to send `contains`, where a substring match is the intent; its
`like` wording is display text for the dropdown and was never an operator.

Exact matching needs an array-aware matcher for the Block column, whose
filter value is its elements joined into one string -- `equals` against
"BlockA, BlockB" could never match a network associated to both. The unused
`arrayTextFilterComparator` is replaced by `arrayTextMatcher`, which
compares each element and honours the filter option in use.

Drill-down still identifies a parent by name alone, so a Block, Virtual
Network or subnet sharing a name with one under a different parent still
returns both. That needs the drill-down to carry a qualified parent
identity and is left alone here.
`GET /api/spaces/{space}/blocks/{block}/available` filtered a network's
prefixes individually and kept the network if any one of them survived, then
overwrote the response with only the surviving prefixes. The association
handlers evaluate the whole network: `PUT` and `POST .../networks` collect
every prefix falling inside the Block and refuse the request if any of them
overlaps an External Network or an outstanding Reservation.

A network with several prefixes inside the Block, one of them overlapping,
was therefore offered as available with the overlapping range removed from
the response. Selecting it was refused with "Network list contains CIDR(s)
that overlap external networks", naming neither the network nor the range,
and the range in question had been stripped from the payload the caller was
shown. The same list is returned to automation without expansion, so an ID
that `PUT .../networks` will reject was handed back as associable.

A network is now excluded outright if any of its in-Block prefixes is
occupied, which is the same basis the association handlers use. Prefixes
outside the Block are still not consulted, so a network drawing address
space from more than one Block is unaffected.

Networks whose address space overlaps one already associated to the Block
are still offered, as the interface replaces the association list rather
than appending to it and a user may deselect one to select the other.
Networks whose in-Block prefixes overlap an External Network or an
unfulfilled Reservation are withheld from the available list. That is
correct, but silent: the caller learns nothing about the network it
expected to see, nor which range stands in its way.

Add an include_blocked flag to the available networks endpoint, which
returns those networks alongside the rest, each carrying a blocked_by
entry naming the External Network or Reservation occupying each prefix.
The flag requires expand, as the unexpanded response is an array of
resource IDs with nowhere to carry a reason.

blocked_by lives on a new NetworkExpandBlocked model rather than on
NetworkExpand, which is shared with the associated networks endpoint
where an always empty blocked_by would be meaningless. The field is
required rather than defaulted, so the response model union resolves a
plain network to NetworkExpand and a reported one to the new model.

Attribution compares every in-Block prefix against every occupant, so it
is computed only for networks that are actually blocked, and only when
the flag is set. The default response is unchanged.
Blocked networks are now returned and rendered, which is what makes the
refusal explainable, but a Block carved up with External Networks can
produce a long list of them and bury the networks that are actually
available.

Add a Showing Available / Showing All toggle to the grid menu, defaulted
to hiding blocked networks and reporting how many are hidden, so the
list stays quiet day to day without the blocked ones becoming invisible
again.

A blocked network which is still associated is never hidden. It is a
state the user has to act on, and the row would otherwise be submitted
on save while not being visible.
Replacing the networks on a Block reported that the list contained a
conflict without saying which entry caused it, leaving the caller to
find it by inspection across a list that can run to dozens of networks.

The three overlap checks tested the merged CIDR set, which had already
discarded the attribution, so each now compares per network and collects
the offenders. The raise conditions are unchanged: a merged set
intersects only when one of its members does, and the existing ordering
still decides which error wins.

This brings the handler into line with itself, as its invalid ID and
outside Block CIDR errors already name the entries at fault.
Save was offered whenever the selection differed from what the Block
holds, even when the selection contained a network the engine will not
accept. Because networks are replaced as a whole, that refusal also took
down any unrelated change made in the same edit.

Two kinds of network cannot be associated: one whose prefixes overlap an
External Network or an unfulfilled Reservation, and one which no longer
resolves in Azure. Both open already selected, as both are associated to
the Block, and the second explained nothing about itself.

Disable Save while either is selected and name it in the button tooltip,
and give an unresolved network the same row tooltip a blocked one
already had.

Report the number of each beside the selection count, so an anomaly is
apparent without scrolling the grid to find it. These are facts about
the Block rather than about the current view, and deliberately do not
track how many rows the filter happens to be hiding.
The eligibility rules described the overlap checks without saying that
they disqualify an entire network, which is the behaviour that surprises
people: a virtual network with several prefixes in the Block is refused
outright when any one of them collides.

State that rule plainly, document the blocked networks the grid now
shows and the toggle which hides them, and note that Save is refused
while an ineligible network is selected.

Also record the side effect of an External Network, which withdraws the
Azure address space it covers from association in that Block.

Closes #200
The no-rows overlay was declared with React.useCallback inside each
consumer, producing a component type whose identity changed whenever its
dependencies did. React responded by unmounting and remounting the
overlay rather than reconciling it, and ESLint reported all nine as
nested component definitions.

An overlay is static content and does not need to be a component type.
The grids now accept a rendered element, consumers build it with
useMemo, and a dependency change yields a new element of the same type
rather than a new type. Reactivity through OverlayContext is unchanged,
as the reference still changes when the dependencies do.

The admin type header was a separate case, closing over nothing at all,
and moves to module scope alongside the other cell components.

This clears the last of the ESLint warnings, so the lint gate now runs
clean rather than against a baseline of ten.
Three AG Grid defaults worked against the row states the association
grid now renders.

Tooltips are not shown until an element has been hovered for two full
seconds, which reads as no tooltip at all rather than a slow one, so a
cell carrying an explanation appeared to carry nothing. The delay is
lowered to 500ms.

The tooltip element sets white-space to normal, collapsing newlines, so
a message describing several ranges ran them into a single line. It is
overridden to pre-line, which honours the newlines while still wrapping
genuinely long text.

A checkbox on a row excluded by isRowSelectable is rendered disabled
rather than omitted, and a disabled checkbox still takes focus, so
clicking a row that can never be selected left a focus ring on a control
with no effect. Those checkboxes are now hidden instead, which only
affects grids supplying isRowSelectable.
The grid tints rows which are stale or blocked and reports a count of
each, but nothing connected the two, so the colours had to be learned
rather than read.

Add a legend beside the counts, each entry carrying a swatch in the
colour of the rows it describes. The tints and the selected row colour
are declared once and consumed by both the grid styling and the legend,
so they cannot drift apart; selected reproduces AG Grid's own accent
mix over the grid background rather than approximating it.

Reaching the refusal reason no longer depends on finding the name
column, as the tooltip is attached to every column, and each overlapping
range is listed on its own line rather than run together.

The blocked count reports how many of those networks the filter is
actually hiding, which is lower than the total whenever one of them is
still associated. The grid menu returns to the wording used for settled
Reservations, the count having moved to the toolbar.
The blocked and stale network sections were written against the
behaviour as designed, and testing moved it: the reason is now shown
from any cell rather than the name column, each overlapping range is
listed on its own line, blocked rows render no checkbox at all, and the
blocked count moved out of the grid menu.

Also document include_blocked alongside expand for direct API consumers,
including the blocked_by shape it adds and the 400 returned when it is
used without expansion.

Remove the screenshot checklist, which had fallen out of date with the
interface it described.
Block names stopped accepting slashes when the character was dropped
from the name pattern, but the messages raised on rejection still list
slashes as permitted, for Blocks and for External Networks, Subnets and
Endpoints alike. Following that advice produces a name the API cannot
address: a slash splits the request URL into extra path segments, so
every route for the resource falls through to the /api catch-all and
answers "Invalid API path.", leaving it unreadable, unrenamable and
undeletable. Percent-encoding offers no escape, as the path is decoded
before it is routed.

Several messages also quote limits their patterns have outgrown,
promising 32 characters where the regex allows 64, and 64 for
descriptions where it allows 128.

Each message now states the rule it is raised for. Validation behaviour
is unchanged.
A drill-down filtered on the parent's name alone, but only a Space name is
unique on its own. Drilling into a Block returned the networks of every
Block sharing that name, across every Space, and drilling into a vNet
returned the subnets of every vNet sharing that name.

Each drill-down now filters on every column needed to identify its parent:
a Block by its Space and name, a vNet by its name, resource group and
subscription. The Space a network belongs to is shown on the network grids
so that filter stays visible and editable, as the search bar's does. The
icon deciding whether a row has children matched on name too, so a Subnet
called "default" offered a drill-down into another Subnet's endpoints, and
it now matches on identity.

Two faults surfaced while testing this. A Block's identity, which pairs its
name with its Space, was only stamped on fetch, so creating or renaming one
left it stale. Associating a network recorded it on the Block but not on
the network, whose parent containers the grids read, leaving the network
unassigned until the next refresh; it is now mirrored the way a rename
already is, without another call to the API.

Networks in Blocks across several Spaces can still match a Space from one
Block and a name from another. Pairing them needs a blended column serving
only this filter, so the extra rows are accepted and noted in the config.
The OpenAPI specification requires that a header parameter named
`Authorization` be ignored, so the per-endpoint token box rendered by the
Swagger UI silently discarded whatever was entered and every "Try it out"
call failed with a 401.

Declare the token as an HTTPBearer security scheme so the global Authorize
button attaches it. The scheme is wired as a sub-dependency of
`validate_token` rather than as a separate router dependency, so an endpoint
cannot advertise authentication without also enforcing it.

Replace the 76 `Header(None, ...)` parameters with
`Depends(get_authorization)`, which keeps the header available to the route
bodies without emitting the parameter OpenAPI discards. Parameter order is
preserved so the existing positional calls between route functions still
bind correctly.

Also document the 401 response, previously reported as "Undocumented", and
remove the long-dead commented-out IPAMToken class.

Behaviour is unchanged for existing UI and API clients: status codes and
response bodies are identical for valid tokens, missing headers, malformed
schemes, and expired tokens.

Fixes #99
`validate_token` wrote the tenant ID onto `request.state` and `check_admin`
wrote the admin flag, which `get_tenant_id` and `get_admin` then read back.
Nothing in the dependency graph expressed that ordering, so a route that used
either accessor without also declaring the auth chain raised AttributeError
and returned a 500 instead of a 401.

Return the values instead: `check_admin` now takes the validated payload and
returns the flag, and the accessors depend on the function that produces what
they need. FastAPI caches dependencies per request, so token validation and
the Cosmos admin lookup still each run exactly once.

No router changes are needed, as every call site already went through
`Depends(get_admin)` or `Depends(get_tenant_id)`. The generated OpenAPI
document is byte-identical.
Contributing was 77% development content: the Docker Compose environment and
the container build instructions were filed under a page about CLAs and pull
requests. Split it into three sections, with Contributing left as the process
of getting a change accepted, Development as working on the code, and
Conventions as the patterns to follow while doing so.

Conventions records the places where the obvious approach is the wrong one:
how admin restrictions are declared and documented, why the API token is a
security scheme rather than a header parameter, and why synchronous HTTP
clients are banned.

The sidebar stays flat so docsify continues to generate sub-entries from each
page's headings; a nested entry suppressed them for the parent page.
…entation

Admin restrictions were enforced by inline `if not is_admin` checks scattered
across the routers, and were invisible in the API documentation. Callers had no
way to tell which endpoints required admin without reading the source.

Replace the inline checks with two dependencies: `require_admin` for endpoints
that are admin-only, and `admin_expand` for endpoints where only the `expand`
parameter is restricted. A generator in the new app/openapi.py module walks each
route's dependency tree and annotates the schema, so the documentation is derived
from the enforcement and cannot drift from it. Parameter-level gates carry the
name of the parameter they guard via `@admin_flag_gate`, so there is no registry
to keep in sync.

Marking is best effort and never fatal. If route introspection breaks, the engine
still serves a valid schema and logs the number of marked operations.

Consolidates two wordings of the same 403 ("API restricted to admins." used 29
times, "This API is admin restricted." used 9 times) into one. Nothing consumes
the string; the UI displays it verbatim. Ownership checks are left inline, as
they are not admin restrictions.
Routes took the full Authorization header and every consumer immediately
stripped the "Bearer " prefix from it, nineteen times across the routers and
their helpers. The header value was never used for anything else.

Depend on `get_token_auth_header` instead, which already existed and already
parsed the header, and pass the token itself down to the helpers. This removes
the duplicated parsing and deletes `get_authorization`, which only existed to
hand the raw header to callers that did not want it.

Error responses are unchanged: router-level dependencies resolve before route
parameters, so token validation still reports the failure. The generated
OpenAPI document is byte-identical.
The security scheme description was a single run-on line in the Swagger UI
Authorize dialog. Swagger renders these as Markdown, so use blank lines for
paragraphs rather than inline HTML. This also lets Swagger generate the link,
which it marks target="_blank" rel="noopener noreferrer".
The Planner laid tiles out as fractions of a 12-column MUI Grid, which sizes
by row position and knows nothing about what it contains. Every viewport
where the fraction landed below the width of a CIDR truncated the mask to an
ellipsis, so 10.183.119.224/31 rendered as 10.183.119.224/... and the only
thing distinguishing one tile from the next was hidden.

The xxl={1} rule made it visible by packing twelve tiles per row at 1920px,
leaving 141px for text needing 150px. Earlier fixes walked the font clamp
down from 20px to 16px to 15px and added an ellipsis to absorb the overflow;
each one moved the breakpoint where the text stopped fitting rather than
removing it.

Lay the tiles out with CSS Grid auto-fill instead, declaring a minimum tile
width and letting the browser choose the column count. The constraint now
runs from the content outward, so no viewport can produce a column too
narrow for the longest possible CIDR. Verified across 320px to 3840px in
20px steps against 255.255.255.255/32 with no truncation.

The minimum is expressed in pixels because the font clamp is: a rem-based
minimum shrank with a reduced browser root font size while the px-clamped
text did not, reintroducing the same truncation at 14px and below.

Drop plannerTheme with it. It existed only to add the xxl breakpoint, and
what remained restated MUI's default values against a root theme that never
overrode them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants