Service ↔ Azure Resource Model¶
This page is the in-repo source of truth for how a Forge service (a logical group of
per-environment Entra ID application registrations) maps to the live Azure resources that
run it. It exists so that any CLI feature reading deployed or runtime state — service describe
image tags, logs, health, cost, restart, config-drift, and so on — can be built against
documented, verified facts instead of reverse-engineered guesses.
Read this before building an Azure-touching feature
Several of the facts below are not derivable from this repository alone (per-environment
subscription IDs and the CLI's Azure scopes live in cloud-foundations). They were verified
against live Azure with az / Azure Resource Graph. The closing
Authoring Azure-touching features checklist captures
what a new feature or issue must state up front.
🧭 The big picture¶
flowchart TD
R["Repository<br/>(exact-name lookup)"]
S["Service<br/>(common key)"]
subgraph envs["Per environment"]
A["Entra app registration<br/>displayName = {projectId}-{env}"]
W["Azure App Service<br/>name = displayName (1:1)"]
SL["0..N deployment slots<br/>(e.g. pr-10028)"]
end
R -. correlated .-> S
S -->|"one facet per env<br/>(test, qa, uat, prod, ...)"| A
A -->|"identical name"| W
W --> SL
W -. "lives in" .-> SUB["Subscription<br/>(one per environment)"]
A service is not a single Azure resource. It is a key (payments) under which one
application registration — and one App Service — exists per environment, and each
environment lives in a different subscription.
🏷️ The naming invariant (1:1)¶
The Azure App Service name is byte-identical to the Entra application-registration display name. There is no transformation, abbreviation, or reconstruction step between the two.
| Plane | Value (example) |
|---|---|
Entra app-registration displayName |
pol-api-exp-policyportal-test |
| Azure App Service name | pol-api-exp-policyportal-test |
The display name follows the platform project-id convention plus an environment suffix:
{domain}-{app type}[-{app sub type}]-{app name}-{env}
└────────────── projectId ──────────────┘ └─ env ─┘
{domain}-{app type}[-{app sub type}]-{app name}is theprojectId(e.g.pol-api-exp-policyportal). Seesaif service describe→ repository correlation for the same{domain}-{app type}{-app sub type}-{app name}shape.{env}is a recognized environment suffix (see below).
Do not reconstruct or short-form the name
The name is identical across environments apart from the suffix — verified byte-for-byte
across prod, qa, test, and uat. Do not short-form the environment
(e.g. prod → prd) and do not rebuild the name from projectId + env with custom
casing. Take the app-registration displayName and use it verbatim as the App Service
name. Building it any other way is the exact mistake that produced an incorrect
implementation sketch in earlier work.
Environment suffixes¶
Only names ending in a known platform environment suffix are service facets. The
authoritative list lives in
ServiceGrouper
(SAIF.Platform.Sdk.Catalog.ServiceGrouper) and is matched longest-suffix-first:
platformdev, prod, test, uat, qa
A registration without one of these suffixes (a free-text or vendor app registration) is not a platform service and is excluded from grouping rather than mapped to a synthetic environment.
🌍 Environment → subscription topology¶
Each environment of a service runs in a different Azure subscription. For a single service,
expect a distinct subscription GUID per environment (verified: four distinct subscriptions for
prod / qa / test / uat).
There is no in-repo environment → subscription map
The per-environment subscription IDs are owned by cloud-foundations, not this
repository. Do not hardcode a subscription map and do not assume a discoverable
env → subscription → resource group mapping exists here — it does not.
Discovery strategy: Azure Resource Graph, by exact name¶
Because the naming invariant guarantees a globally unique, exact App Service name, the supported way to locate a service's resources is an Azure Resource Graph (ARG) query by exact name across all subscriptions the caller can see — never a hardcoded subscription/resource-group map.
Resources
| where type =~ 'microsoft.web/sites'
| where name =~ 'pol-api-exp-policyportal-test'
| project name, resourceGroup, subscriptionId, location, tags, properties.siteConfig.linuxFxVersion
This automatically spans the per-environment subscriptions without the CLI knowing which subscription an environment maps to, and it degrades gracefully: a subscription the caller cannot read simply returns no row instead of failing the whole query.
🗂️ The catalog data model (lossy by design)¶
The service catalog is built in two stages, and the second stage discards information the first stage read. Knowing this prevents you from expecting the App Service name to be available downstream when it is not.
| Stage | Type | Keeps | Discards |
|---|---|---|---|
| Read | EntraServiceCatalogProvider |
Full app-registration displayName, appId, identifierUris |
— |
| Group | ServiceGrouper.Group |
Common key, environment suffix, AppId, identifier URI (per env) |
The full display name / base name detail |
The consequence: after grouping, the catalog knows a service's key and per-environment AppId,
but not the literal App Service name. A feature that needs the App Service name must either
reconstruct it from the catalog key + environment suffix (which reproduces the
naming invariant exactly), or re-read the source app registration.
Prefer re-reading the registration displayName when accuracy matters — it is the invariant's
authoritative side.
🔖 The resource tag contract¶
Deployed-version information is read from the App Service resource tags and site config, which the deploy pipeline sets through the Terraform service modules.
build_number¶
The Forge service modules set a build_number tag from the deploying pipeline's build number via
their common_tags:
# src/templates/saif-feature-api/infra/api/app.generated.tf (and web equivalent)
locals {
common_tags = {
deployed_by = var.deployed_by
application_name = local.variables.application_name
build_number = var.build_number # ← snake_case here
team_name = local.variables.owner
}
}
| Owner module | Registry source | Sets tag as |
|---|---|---|
| API services | app.terraform.io/SAIFCorp/saif-apiservice/azure |
build_number |
| Web apps | app.terraform.io/SAIFCorp/saif-webapp/azure |
build_number |
Casing and presence vary by plane
- Casing is not guaranteed. The templated modules above emit
build_number(snake_case), but other planes / older deployments may carryBuildNumber(PascalCase). Read the tag case-insensitively. - Slots carry no build tag. A deployment slot does not get the
build_numbertag — only the main site does. Read a slot's version from its image (linuxFxVersion) instead.
linuxFxVersion (the deployed image)¶
The deployed container image is read from the site config, not a tag:
Parse the :{tag} portion for the image tag. This is the most reliable per-plane (site and
slot) source of "what is actually running", because every slot has its own linuxFxVersion even
when it has no build_number tag.
🧩 Deployment slots¶
App Service deployment slots are zero-or-more and arbitrarily named — not a single fixed "staging" slot.
- A service may have no slots, or several.
- Slot names are feature-driven. PR-preview slots are named
pr-{number}(e.g.pr-10028); see PR slot deployments. - Each slot has its own image (
linuxFxVersion) and therefore its own deployed version. - Slots do not inherit the main site's
build_numbertag.
A feature that reports deployed state must enumerate slots dynamically and read each slot's
linuxFxVersion independently, rather than assuming one well-known slot.
🔐 Azure-read prerequisite (not in place today)¶
The CLI has no Azure Resource Manager awareness today, and the CLI's own application registration lacks the scope required to read ARM/ARG on the user's behalf:
- Required: the delegated Azure Service Management
user_impersonationscope (https://management.azure.com/user_impersonation) on the CLI app registration, plus tenant admin consent. - This is an infrastructure change in
cloud-foundations, not a code change in this repo.
Distinct from a service's own API scope
This Azure Service Management user_impersonation is not the same as the per-service
api://{projectId}-{env}/user_impersonation scope described in
How Authentication Works. The former authorizes calls to
Azure Resource Manager; the latter authorizes calls to a Forge API. A feature needing live
Azure reads depends on the former and must treat it as a prerequisite, not an assumption.
✅ Authoring Azure-touching features¶
Any Forge feature or CLI command that reads or operates on live Azure resources should state,
up front, how it handles each of the following — and verify the assumptions against live Azure
(az / Azure Resource Graph) before finalizing:
- Naming invariant(s) it relies on — and that it uses the app-registration
displayNameverbatim as the App Service name (no short-forming, no reconstruction with custom casing). - Discovery strategy — default to ARG by exact name across visible subscriptions; do
not hardcode an
env → subscription → resource groupmap. - Required Azure scopes / app-registration changes and where they live — e.g. the
Azure Service Management
user_impersonationscope and per-environment subscription IDs are owned bycloud-foundations, not this repo. - Tag contract assumptions — read
build_numbercase-insensitively, expect it to be absent on slots, and preferlinuxFxVersionfor per-plane image/version. - Slot handling — enumerate zero-or-more arbitrarily named slots; never assume a single
stagingslot. - Best-effort degradation — a failure for one environment/subscription (no access, no resource, transient error) must not sink the whole view; surface per-environment status and keep going.
🔗 Related¶
| Topic | Link |
|---|---|
saif service search / describe |
SAIF CLI reference |
| Token acquisition and scopes | How Authentication Works |
| Environment flow and approvers | Environments |
| PR-preview deployment slots | PR Slot Deployments |