---
title: Service ↔ Azure Resource Model
description: How a Forge service maps to its live Azure resources — the naming invariant, environment-to-subscription topology, resource discovery strategy, tag contract, and deployment slots. The source of truth for CLI features that read live deployed state.
---

# 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.

!!! abstract "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](#authoring-azure-touching-features) checklist captures
    what a new feature or issue must state up front.

---

## 🧭 The big picture

```mermaid
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:

```text
{domain}-{app type}[-{app sub type}]-{app name}-{env}
└────────────── projectId ──────────────┘ └─ env ─┘
```

- `{domain}-{app type}[-{app sub type}]-{app name}` is the `projectId`
  (e.g. `pol-api-exp-policyportal`). See
  [`saif service describe` → repository correlation](tools/saifcli.md#saif-service-describe)
  for the same `{domain}-{app type}{-app sub type}-{app name}` shape.
- `{env}` is a recognized environment suffix (see [below](#environment-suffixes)).

!!! warning "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`](https://github.com/saif-corp/forge/blob/main/src/dotnet/SAIF.Platform.Sdk/Catalog/ServiceGrouper.cs)
(`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`).

!!! danger "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.

```kql
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`](https://github.com/saif-corp/forge/blob/main/src/dotnet/SAIF.Platform.Sdk/Catalog/Providers/EntraServiceCatalogProvider.cs) | Full app-registration `displayName`, `appId`, `identifierUris` | — |
| Group | [`ServiceGrouper.Group`](https://github.com/saif-corp/forge/blob/main/src/dotnet/SAIF.Platform.Sdk/Catalog/ServiceGrouper.cs) | 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](#the-naming-invariant-11) 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`:

```hcl
# 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` |

!!! warning "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 carry `BuildNumber` (PascalCase). Read the tag
      **case-insensitively**.
    - **Slots carry no build tag.** A deployment [slot](#deployment-slots) does not get the
      `build_number` tag — 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:

```text
properties.siteConfig.linuxFxVersion = "DOCKER|{registry}/{repo}:{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](../guides/development/pr-slot-deployments.md) 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](../guides/development/pr-slot-deployments.md).
- **Each slot has its own image** (`linuxFxVersion`) and therefore its own deployed version.
- Slots do **not** inherit the main site's `build_number` tag.

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_impersonation`** scope
  (`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.

!!! note "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](how-authentication-works.md). 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 `displayName`
      verbatim 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 group` map.
- [ ] **Required Azure scopes / app-registration changes** and **where they live** — e.g. the
      Azure Service Management `user_impersonation` scope and per-environment subscription IDs are
      owned by `cloud-foundations`, not this repo.
- [ ] **Tag contract assumptions** — read `build_number` case-insensitively, expect it to be
      absent on slots, and prefer `linuxFxVersion` for per-plane image/version.
- [ ] **Slot handling** — enumerate zero-or-more arbitrarily named slots; never assume a single
      `staging` slot.
- [ ] **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](tools/saifcli.md#saif-service-describe) |
| Token acquisition and scopes | [How Authentication Works](how-authentication-works.md) |
| Environment flow and approvers | [Environments](environments.md) |
| PR-preview deployment slots | [PR Slot Deployments](../guides/development/pr-slot-deployments.md) |
