Build a small production-style sample application that runs on Azure App Service, uses Flask, stores login audit data in Azure SQL Database, and relies on Azure App Service Authentication ("Easy Auth") with Microsoft Entra ID for user sign-in.
The application must:
- Require users to authenticate before accessing the app.
- Use the web app's system-assigned managed identity to connect to Azure SQL Database without a SQL username/password in application settings.
- Record a login event for each authenticated user.
- Show recorded user and application login events on a dashboard after sign-in.
- Expose a protected JSON API endpoint that returns recent login events.
- Allow a Microsoft Entra daemon client application to call the login events API by using OAuth 2.0 client credentials.
- Use the current Azure SQL Database free offer for the sample database so the baseline deployment stays in the no-cost tier when the subscription is eligible and monthly limits are not exceeded.
This document is the implementation contract. An agent should be able to build the app and the infrastructure from this file alone.
- One Flask web application deployed to Azure App Service.
- One Azure SQL logical server and one Azure SQL Database.
- Easy Auth configured on the App Service app with Microsoft Entra ID.
- One Azure Key Vault used to store generated Microsoft Entra client secrets.
- A simple schema for login audit records.
- A dashboard page that displays the recorded login data.
- A JSON API endpoint that returns recent login audit rows.
- A daemon client application registration that can be granted application permission to the login events API.
- Azure CLI commands to provision and configure the required Azure resources.
- Complex authorization such as RBAC inside the app.
- Multi-tenant sign-in.
- Background jobs, queues, or analytics pipelines.
- CI/CD pipeline setup.
- Advanced UI frameworks.
- Local Docker support.
The solution uses these resources:
- Resource group.
- App Service plan.
- Azure App Service (Linux, Python runtime).
- Azure SQL logical server.
- Azure SQL Database configured for the current Azure SQL Database free offer.
- Azure Key Vault for generated client secrets.
- One Microsoft Entra app registration for Easy Auth.
The authentication model is:
- A browser requests the web app.
- Easy Auth intercepts unauthenticated requests.
- Easy Auth redirects the user to Microsoft Entra ID.
- After successful sign-in, Easy Auth injects authenticated user information into request headers.
- Flask reads the injected headers and treats the request as authenticated.
- Flask writes a login record into Azure SQL.
- Flask renders the dashboard with recent user and application login events.
The application must not implement its own OpenID Connect flow in Flask. Authentication is handled by App Service Authentication.
The machine-to-machine access model is:
- A daemon application is registered in Microsoft Entra ID as a confidential client.
- The App Service app registration exposes an API Application ID URI and an application permission for reading login events.
- The daemon application receives admin consent for that application permission.
- The daemon application requests an access token by using the OAuth 2.0 client credentials grant.
- The daemon sends
Authorization: Bearer <token>toGET /api/logins. - App Service Authentication validates the token and injects principal headers for Flask.
- Flask authorizes the application principal by checking the required app role before returning the JSON payload.
This flow is app-only. It must not depend on an interactive user session.
The database access model is:
- The App Service web app has a system-assigned managed identity enabled.
- Azure SQL is configured for Microsoft Entra authentication.
- The managed identity is created as a contained database user in the target database.
- Flask obtains an access token for
https://database.windows.net/.default. - Flask connects to Azure SQL using that token and the ODBC SQL Server driver.
The application must not store a SQL password in code, repo files, or app settings.
These decisions are fixed to reduce ambiguity for the implementing agent.
- Language: Python 3.12.
- Web framework: Flask.
- Database driver:
pyodbc. - Azure identity library:
azure-identity. - Optional packaging:
requirements.txt. - App entrypoint:
app.py. - Microsoft Entra daemon flow: OAuth 2.0 client credentials.
These values are part of the implementation contract:
- App role value required for user access to
GET /dashboardand user-sideGET /api/logins:dashboard_read - App role value required for daemon access to
GET /api/logins:api_read - App role value required for user access to
POST /dashboard/logins/clear:dashboard_write - Default Application ID URI for the App Service API:
api://<easy-auth-client-id>
- The Easy Auth app registration client secret must be stored in Azure Key Vault.
- The daemon client app registration secret must be stored in Azure Key Vault when the daemon client is created.
- The Azure Key Vault must use Azure RBAC for data-plane authorization rather than legacy access policies.
- The App Service app setting
MICROSOFT_PROVIDER_AUTHENTICATION_SECRETmust be configured as an App Service Key Vault reference, not as a raw secret value. - The web app must use its system-assigned managed identity to resolve Key Vault references.
- The web app managed identity must be assigned the
Key Vault Secrets Userrole on the vault. - The identity that provisions or rotates secrets must be assigned a write-capable Key Vault data-plane role such as
Key Vault Secrets OfficerorKey Vault Administrator. - Automated teardown must purge the soft-deleted Azure Key Vault so
terraform destroyfully removes the vault instead of leaving it recoverable for the retention window.
- Hosting target is Azure App Service on Linux.
- Easy Auth is enabled at the platform level.
- Unauthenticated requests are redirected to Microsoft Entra sign-in.
- HTTPS is required.
The Azure SQL Database for this sample must use the current Azure SQL Database free offer baseline instead of the legacy Basic tier.
Use these assumptions:
- Service tier:
GeneralPurpose - Compute tier:
Serverless - Free offer enabled with
--use-free-limit - Free-limit exhaustion behavior:
AutoPause - Backup storage redundancy:
Local
This keeps the sample aligned with the current Azure SQL free-offer model described by Microsoft Learn, where free usage is applied to eligible General Purpose serverless databases rather than DTU-based Basic databases.
The application must use Easy Auth request headers as the source of user identity.
Preferred approach:
- Read
X-MS-CLIENT-PRINCIPALfrom the incoming request. - Base64-decode the header value.
- Parse the JSON payload.
- Extract claims needed for:
- User object ID.
- Display name.
- Preferred username or email.
- Identity provider.
The implementing agent should assume the decoded payload uses the common Easy Auth shape:
{
"auth_typ": "aad",
"name_typ": "name",
"role_typ": "roles",
"claims": [
{ "typ": "name", "val": "Alice Smith" },
{ "typ": "preferred_username", "val": "alice@contoso.com" },
{
"typ": "http://schemas.microsoft.com/identity/claims/objectidentifier",
"val": "00000000-0000-0000-0000-000000000000"
}
]
}Use this claim precedence:
- Object ID:
http://schemas.microsoft.com/identity/claims/objectidentifier- fallback
oid
- Display name:
name
- Email / username:
preferred_username- fallback
email - fallback
upn
If the decoded principal payload is missing, the request should be treated as unauthenticated and return HTTP 401 for JSON endpoints or redirect to /.auth/login/aad for browser routes.
The daemon client calls are also delivered through Easy Auth headers. The Flask app must support application principals in addition to user principals.
Preferred claim handling for application principals:
- Client application ID:
azp- fallback
appid - fallback
client_id
- Application object ID:
http://schemas.microsoft.com/identity/claims/objectidentifier- fallback
oid
- App roles:
roles
- App-only hint:
idtyp=appwhen present- otherwise treat the token as app-only when
oid == suband a client application ID exists
Implementation rule:
- The app must distinguish between:
- interactive user principals
- application principals
GET /dashboardmust allow only interactive user principals that contain either thedashboard_readordashboard_writeapp role.POST /dashboard/logins/clearmust allow interactive user principals that contain thedashboard_writeapp role.GET /api/loginsmust allow:- interactive user principals that contain either the
dashboard_readordashboard_writeapp role - application principals that contain the
api_readapp role
- interactive user principals that contain either the
All user-facing app routes must require authentication.
- Anonymous users must not be able to access the dashboard.
- App Service Authentication should enforce this before Flask handles the request.
- The app must expose
/healthzanonymously for operational checks.
The app must record one login event whenever an authenticated user first reaches the dashboard in a new browser session.
Implementation rule:
- Use Flask session state to avoid inserting duplicate audit rows on page refresh in the same browser session.
- Set a session flag after the first successful insert.
- If a new browser session is started, insert a new login row again.
This rule is intentionally simple and sufficient for the sample app.
Additional rule for daemon access:
- When an authorized application principal successfully calls
GET /api/logins, the app must record an application login event. - This application event should be recorded per successful API call.
The dashboard must show:
- The current signed-in user summary.
- Separate tables for recent user and application login events.
- A button that clears all stored login rows from the database when the signed-in user has the
dashboard_writeapp role.
The user login table must include:
- Login timestamp in UTC.
- Display name.
- Email or preferred username.
- Microsoft Entra object ID.
The application login table must include:
- Login timestamp in UTC.
- Application display name.
- Client application ID.
- Microsoft Entra object ID.
The app must expose a protected JSON endpoint for recent login events.
Implementation rules:
- Route:
GET /api/logins - Authentication is required.
- The endpoint returns HTTP 200 with
application/json. - The response body must be an object with a
login_eventsarray. - Each item in
login_eventsmust include:login_atprincipal_typedisplay_nameemailclient_app_idaad_object_ididentity_provider
- Rows must be ordered from newest to oldest.
- The endpoint should return the same most recent 50 audit rows used by the dashboard tables.
- The endpoint must insert an audit row when the caller is an authorized application principal.
- The endpoint should not insert an additional audit row when the caller is a user principal that is only reading the API.
- The endpoint must allow:
- authenticated user principals that contain either the
dashboard_readordashboard_writeapp role - authenticated application principals with the
api_readapp role
- authenticated user principals that contain either the
- The endpoint must reject authenticated principals that do not have the required role for their principal type.
If the request is unauthenticated, the endpoint must return HTTP 401 with a JSON body:
{
"error": "authentication_required"
}If the request is authenticated but the application principal is missing the required app role, the endpoint must return HTTP 403 with a JSON body:
{
"error": "insufficient_role"
}The application should initialize the required table if it does not exist yet.
Implementation rule:
- Schema creation may happen during app startup or through a dedicated helper function called before querying.
- Table creation must be idempotent.
The implementation should prefer the smallest number of files and moving parts that still keep the app understandable.
The implementation should use these routes unless there is a strong reason to change them:
GET /- Redirects to
/dashboard.
- Redirects to
GET /dashboard- Requires authentication.
- Allows only authenticated user principals that contain either the
dashboard_readordashboard_writeapp role. - Records the login event for the current browser session if not already recorded.
- Loads recent user and application login rows from Azure SQL.
- Renders the main HTML page.
POST /dashboard/logins/clear- Requires authentication.
- Allows only authenticated user principals that contain the
dashboard_writeapp role. - Deletes all rows from
dbo.user_logins. - Redirects back to
/dashboard.
GET /api/logins- Requires authentication.
- Allows authenticated user principals that contain either the
dashboard_readordashboard_writeapp role. - Allows authenticated application principals that contain the
api_readapp role. - Records an application login row when called by an authorized application principal.
- Loads recent user and application login rows from Azure SQL.
- Returns the rows as JSON.
- Does not insert an additional audit row for user principals that are only reading the API.
GET /healthz- Returns
200 OKand a simple body likeokwhen the app can reach Azure SQL. - Returns
503 Service Unavailablewhen the database connectivity check fails. - Must remain anonymous to support health checks.
- Returns
GET /.auth/me- Provided by Easy Auth, not implemented by Flask.
GET /.auth/login/aad- Provided by Easy Auth, not implemented by Flask.
GET /.auth/logout- Provided by Easy Auth, not implemented by Flask.
Use one table named user_logins.
Required columns:
id INT IDENTITY(1,1) PRIMARY KEYaad_object_id NVARCHAR(64) NOT NULLprincipal_type NVARCHAR(32) NOT NULLdisplay_name NVARCHAR(256) NOT NULLemail NVARCHAR(256) NULLclient_app_id NVARCHAR(64) NULLidentity_provider NVARCHAR(64) NOT NULLlogin_at DATETIMEOFFSET NOT NULL DEFAULT SYSDATETIMEOFFSET()
- Primary key on
id. - Nonclustered index on
login_at DESC. - Optional nonclustered index on
aad_object_id.
IF NOT EXISTS (
SELECT 1
FROM sys.tables
WHERE name = 'user_logins'
)
BEGIN
CREATE TABLE dbo.user_logins (
id INT IDENTITY(1,1) PRIMARY KEY,
aad_object_id NVARCHAR(64) NOT NULL,
principal_type NVARCHAR(32) NOT NULL
CONSTRAINT DF_user_logins_principal_type DEFAULT 'user',
display_name NVARCHAR(256) NOT NULL,
email NVARCHAR(256) NULL,
client_app_id NVARCHAR(64) NULL,
identity_provider NVARCHAR(64) NOT NULL,
login_at DATETIMEOFFSET NOT NULL
CONSTRAINT DF_user_logins_login_at DEFAULT SYSDATETIMEOFFSET()
);
CREATE INDEX IX_user_logins_login_at
ON dbo.user_logins (login_at DESC);
CREATE INDEX IX_user_logins_aad_object_id
ON dbo.user_logins (aad_object_id);
END
IF COL_LENGTH('dbo.user_logins', 'principal_type') IS NULL
BEGIN
ALTER TABLE dbo.user_logins
ADD principal_type NVARCHAR(32) NOT NULL
CONSTRAINT DF_user_logins_principal_type_upgrade DEFAULT 'user' WITH VALUES;
END
IF COL_LENGTH('dbo.user_logins', 'client_app_id') IS NULL
BEGIN
ALTER TABLE dbo.user_logins
ADD client_app_id NVARCHAR(64) NULL;
ENDThe app should use environment variables for deploy-time configuration.
Required app settings:
SQL_SERVER_NAME- Example:
myapp-sqlsrv.database.windows.net
- Example:
SQL_DATABASE_NAME- Example:
myappdb
- Example:
FLASK_SECRET_KEY- Used for Flask session signing.
Optional app settings:
PORTWEBSITES_PORTDASHBOARD_READ_APP_ROLEAPI_READ_APP_ROLEDASHBOARD_WRITE_APP_ROLE
The app must not define or expect:
SQL_USERNAMESQL_PASSWORD
The app must:
- Obtain an Entra access token using
DefaultAzureCredential. - Request the scope
https://database.windows.net/.default. - Connect to Azure SQL using ODBC Driver 18 for SQL Server.
- Encrypt the connection.
- Validate the certificate.
Use a helper function similar to this behavior:
- Read server and database from environment variables.
- Get token bytes for Azure SQL.
- Pass token to
pyodbc.connectusing the SQL access token attribute. - Use a short timeout.
The effective connection settings must include:
- Driver:
ODBC Driver 18 for SQL Server - Server:
<server>.database.windows.netor full hostname from app settings - Database: target database name
- Encrypt:
yes - TrustServerCertificate:
no
- Identity provider: Microsoft Entra ID only.
- App Service Authentication must be enabled.
- Unauthenticated requests must be redirected to sign-in.
- The site must use the Microsoft provider as the default sign-in provider.
- The Easy Auth app registration must explicitly request the Microsoft Graph delegated permissions
openid,profile, andemail. - The App Service app registration must expose an Application ID URI that daemon clients can request with
/.default. - The App Service Authentication configuration must accept the Application ID URI as an allowed audience.
Flask should trust Easy Auth only when the app is running behind App Service Authentication. The code should not assume the same headers are trustworthy in arbitrary hosting environments.
Because the sample hosts both browser pages and the API in the same App Service site, authorization must be enforced in Flask for the daemon-specific app role check on GET /api/logins.
Important reasoning:
- App Service built-in allowed-client authorization checks are site-wide.
- This sample still needs interactive browser access to
/dashboard. - Therefore, the daemon authorization rule must be enforced in route-specific application code even if platform-level allowlists are also configured later for a dedicated API deployment.
Azure SQL must be configured so the App Service managed identity can access the database.
Required steps:
- Set a Microsoft Entra admin on the Azure SQL logical server.
- Enable the App Service system-assigned managed identity.
- Create a contained database user for the managed identity.
- Grant the minimum required permissions.
Minimum database permissions for the sample app:
db_datareaderdb_datawriterdb_ddladmin
db_ddladmin is included only because the app is expected to create the table if missing. If schema creation is moved to a separate deployment step, the runtime app should not receive db_ddladmin.
The dashboard should be simple server-rendered HTML.
Required UI elements:
- Page title.
- Current user summary card.
- Separate tables for recent user and application logins.
- Empty state when no rows exist.
- Sign-out link pointing to
/.auth/logout.
Required display rules:
- Show timestamps in UTC and label them as UTC.
- Sort rows by newest first.
- Limit the dashboard audit data to the most recent 50 rows before splitting it by principal type.
The app must handle these failures cleanly:
- Missing Easy Auth headers.
- Failure to obtain managed identity token.
- Failure to connect to Azure SQL.
- Failure to create the schema.
- Failure to insert login audit row.
- Failure to query recent login rows.
Minimum behavior:
- Log the error on the server.
- Return HTTP 500 with a simple user-facing message for browser requests.
- Return HTTP 401 or HTTP 500 with a JSON error body for JSON API endpoints.
- Return HTTP 403 with a JSON error body when an authenticated application principal lacks the required app role.
- Do not expose secrets or raw token contents in logs.
The repository should remain small, but it is expected to include the Flask app, tests, scripts, and the modular Terraform layout. A recommended structure is:
.
├── app.py
├── infra/
│ └── terraform/
│ ├── modules/
│ │ └── app-stack/
│ │ ├── app_service.tf
│ │ ├── auth.tf
│ │ ├── core.tf
│ │ ├── data.tf
│ │ ├── key_vault.tf
│ │ ├── locals.tf
│ │ ├── outputs.tf
│ │ ├── random.tf
│ │ ├── scripts/
│ │ │ └── configure_sql_database_access.py
│ │ ├── sql.tf
│ │ ├── variables.tf
│ │ ├── versions.tf
│ │ └── README.md
│ └── environments/
│ └── dev/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── dev.auto.tfvars
├── requirements.txt
├── scripts/
│ ├── deploy_app_only.sh
│ └── test_daemon_api.sh
├── templates/
│ └── dashboard.html
├── tests/
│ └── test_app.py
└── docs/
└── spec.md
If the implementing agent prefers a small db.py or auth.py helper module, that is acceptable, but not required.
The Terraform implementation should use a reusable module and environment-specific entry points:
- The reusable module must live at
infra/terraform/modules/app-stack. - The current environment entry point must live at
infra/terraform/environments/dev/main.tf. - Generic environment values for that entry point must be stored in
infra/terraform/environments/dev/terraform.tfvars. - Environment-specific values such as object IDs, firewall IPs, and similar potentially confidential overrides must be stored in
infra/terraform/environments/dev/dev.auto.tfvars. <environment>.auto.tfvarsfiles are local-only inputs and must not be committed.- Additional environments such as
stagingandprodmay be added later by following the same directory pattern.
The app-stack module must preserve the current infrastructure behavior while improving modularity and maintainability:
- The required module inputs are
nameandenvironment. - Other module inputs should remain optional where reasonable.
- The intended module defaults are:
app_plan_sku = "F1"python_version = "3.12"sql_db_edition = "GeneralPurpose"sql_db_family = "Gen5"sql_db_capacity = 2sql_db_compute_model = "Serverless"sql_db_auto_pause_delay = 60sql_db_backup_redundancy = "Local"sql_db_free_limit_exhaustion_behavior = "AutoPause"create_webapp_managed_identity_db_user = true
- The module must expose
sql_database_accessfor additional Microsoft Entra contained database users and database role grants. The web app managed identity must be merged into the effective access map by default whencreate_webapp_managed_identity_db_user = true. - The following inputs must remain optional without baked-in defaults and, when used, must be defined explicitly in the environment
<environment>.auto.tfvarsfile:app_role_authorizationssql_database_accesssql_firewall_allowed_ipv4_addresses
Terraform resource naming must follow a consistent stack-oriented convention:
- Resource names must be derived from
nameandenvironment. - A shared Terraform
random_idsuffix must be used across the full deployment stack. - The naming pattern must be
<prefix>-<name>-<environment>-<random>, for examplerg-<name>-<environment>-<random>,sql-<name>-<environment>-<random>, andapp-<name>-<environment>-<random>. - Every resource name must include the random suffix.
Terraform must also generate an environment file for downstream scripts:
- The generated file path must be
infra/terraform/environments/<environment>/<environment>.env, for exampleinfra/terraform/environments/dev/dev.env. - That file must be generated during
terraform apply, for example through alocal_fileresource. - It should contain only the subset of values required by the operational scripts such as
scripts/deploy_app_only.shandscripts/test_daemon_api.sh. - The old
scripts/deploy.envconvention should not be used.
The implementation should proceed in this order:
- Create the resource group.
- Create the App Service plan.
- Create the web app with Python runtime.
- Enable the system-assigned managed identity.
- Create the Azure Key Vault in Azure RBAC mode and grant the required Key Vault roles.
- Create the Azure SQL logical server.
- Create the Azure SQL Database.
- Configure server firewall access as needed for setup tasks.
- Set a Microsoft Entra admin on the SQL server.
- Create the Microsoft Entra app registration used by Easy Auth.
- Add the redirect URI for App Service authentication.
- Store the Easy Auth client secret in Azure Key Vault.
- Configure Easy Auth on the web app using the Key Vault-backed app setting.
- Create a database user mapped to the App Service managed identity.
- Grant the database permissions required by the app.
- Create the Flask app skeleton.
- Implement Easy Auth principal parsing.
- Implement managed identity Azure SQL connection helper.
- Implement idempotent schema creation.
- Implement login audit insert logic.
- Implement dashboard query and rendering.
- Add login events JSON endpoint.
- Add daemon-application principal parsing and app-role authorization for
GET /api/logins. - Add health endpoint.
- Deploy the Flask code to App Service.
- Configure app settings.
- Browse to the site.
- Confirm redirect to Microsoft sign-in.
- Confirm successful sign-in.
- Confirm the login row is inserted.
- Confirm recent logins appear on the dashboard.
The following command set is intended as the provisioning baseline. Replace placeholder values before execution.
RG="rg-flask-sql-auth"
LOCATION="westeurope"
APP_PLAN="plan-flask-sql-auth"
WEBAPP_NAME="app-flask-sql-auth-weeu-01"
KEY_VAULT_NAME="kvflasksqlauthweeu01"
SQL_SERVER_NAME="sql-flask-sql-auth-weeu-01"
SQL_DB_NAME="appdb"
SQL_DB_EDITION="GeneralPurpose"
SQL_DB_FAMILY="Gen5"
SQL_DB_CAPACITY="2"
SQL_DB_COMPUTE_MODEL="Serverless"
SQL_DB_AUTO_PAUSE_DELAY="60"
SQL_DB_BACKUP_REDUNDANCY="Local"
SQL_DB_FREE_LIMIT_EXHAUSTION_BEHAVIOR="AutoPause"
RUNTIME="PYTHON|3.12"
TENANT_ID="$(az account show --query tenantId -o tsv)"
SUBSCRIPTION_ID="$(az account show --query id -o tsv)"
# Microsoft Entra admin for the SQL server.
# Use either a user or group that is allowed to administer Azure SQL.
SQL_AAD_ADMIN_NAME="Steven Mertens"
SQL_AAD_ADMIN_OBJECT_ID="595d861c-6322-4ca1-a607-4e502649c6aa"
# Easy Auth app registration values.
AAD_APP_NAME="app-${WEBAPP_NAME}"
AAD_APP_REDIRECT_URI="https://${WEBAPP_NAME}.azurewebsites.net/.auth/login/aad/callback"
AAD_APP_IDENTIFIER_URI="api://<easy-auth-client-id>"
DASHBOARD_READ_APP_ROLE="dashboard_read"
DASHBOARD_WRITE_APP_ROLE="dashboard_write"
API_READ_APP_ROLE="api_read"
DAEMON_APP_NAME="${WEBAPP_NAME}-daemon"
EASY_AUTH_SECRET_NAME="easy-auth-client-secret"
DAEMON_APP_SECRET_NAME="daemon-client-secret"Notes:
- The current Azure SQL free offer applies to eligible General Purpose serverless databases, not to the legacy
Basictier. - Microsoft documents the free offer as including 100,000 vCore seconds, 32 GB of data storage, and 32 GB of backup storage per free database each month. The free-offer article states that up to 10 databases are supported per subscription, while current Azure CLI help for
--use-free-limitstill says one database per subscription. This sample only requires one free database, so no multi-database assumption is needed. - If the subscription already contains a free-offer database created with advanced configuration, Azure may require subsequent free-offer databases in that subscription to use the same region.
az group create \
--name "$RG" \
--location "$LOCATION"az appservice plan create \
--name "$APP_PLAN" \
--resource-group "$RG" \
--location "$LOCATION" \
--is-linux \
--sku F1az webapp create \
--resource-group "$RG" \
--plan "$APP_PLAN" \
--name "$WEBAPP_NAME" \
--runtime "$RUNTIME"az webapp identity assign \
--resource-group "$RG" \
--name "$WEBAPP_NAME"Capture the principal ID because it identifies the managed identity in Microsoft Entra:
WEBAPP_MI_PRINCIPAL_ID="$(az webapp identity assign \
--resource-group "$RG" \
--name "$WEBAPP_NAME" \
--query principalId -o tsv)"az keyvault create \
--name "$KEY_VAULT_NAME" \
--resource-group "$RG" \
--location "$LOCATION" \
--enable-rbac-authorization trueGrant the web app managed identity the Key Vault Secrets User role:
KEY_VAULT_ID="$(az keyvault show \
--name "$KEY_VAULT_NAME" \
--resource-group "$RG" \
--query id -o tsv)"
az role assignment create \
--assignee-object-id "$WEBAPP_MI_PRINCIPAL_ID" \
--assignee-principal-type ServicePrincipal \
--role "Key Vault Secrets User" \
--scope "$KEY_VAULT_ID"Before storing secrets, the identity running the provisioning commands must also have a write-capable data-plane role on the vault, such as Key Vault Secrets Officer or Key Vault Administrator.
az sql server create \
--name "$SQL_SERVER_NAME" \
--resource-group "$RG" \
--location "$LOCATION" \
--enable-ad-only-auth trueaz sql db create \
--resource-group "$RG" \
--server "$SQL_SERVER_NAME" \
--name "$SQL_DB_NAME" \
--edition "$SQL_DB_EDITION" \
--family "$SQL_DB_FAMILY" \
--capacity "$SQL_DB_CAPACITY" \
--compute-model "$SQL_DB_COMPUTE_MODEL" \
--auto-pause-delay "$SQL_DB_AUTO_PAUSE_DELAY" \
--backup-storage-redundancy "$SQL_DB_BACKUP_REDUNDANCY" \
--use-free-limit true \
--free-limit-exhaustion-behavior "$SQL_DB_FREE_LIMIT_EXHAUSTION_BEHAVIOR"Rationale:
Basicis the old DTU-based tier and does not match the current Azure SQL free offer.- The Azure CLI now supports free-offer creation through
--use-free-limit. AutoPauseis the safer default for this sample because it avoids overage charges if the monthly free allowance is exhausted.Localbackup redundancy is explicitly specified because it is the applicable backup mode when the free database is configured to auto-pause at the free limit.
az sql server ad-admin create \
--resource-group "$RG" \
--server "$SQL_SERVER_NAME" \
--display-name "$SQL_AAD_ADMIN_NAME" \
--object-id "$SQL_AAD_ADMIN_OBJECT_ID"This is acceptable for a sample app. A stricter production design would use private networking instead.
az sql server firewall-rule create \
--resource-group "$RG" \
--server "$SQL_SERVER_NAME" \
--name "AllowAzureServices" \
--start-ip-address 0.0.0.0 \
--end-ip-address 0.0.0.0AAD_APP_CLIENT_ID="$(az ad app create \
--display-name "$AAD_APP_NAME" \
--web-redirect-uris "$AAD_APP_REDIRECT_URI" \
--query appId -o tsv)"Create a client secret:
AAD_APP_CLIENT_SECRET="$(az ad app credential reset \
--id "$AAD_APP_CLIENT_ID" \
--append \
--query password -o tsv)"Store the Easy Auth secret in Key Vault:
az keyvault secret set \
--vault-name "$KEY_VAULT_NAME" \
--name "$EASY_AUTH_SECRET_NAME" \
--value "$AAD_APP_CLIENT_SECRET"If the role assignment was created immediately beforehand, allow for RBAC propagation before running az keyvault secret set.
Set the Application ID URI:
AAD_APP_IDENTIFIER_URI="api://${AAD_APP_CLIENT_ID}"
az ad app update \
--id "$AAD_APP_CLIENT_ID" \
--identifier-uris "$AAD_APP_IDENTIFIER_URI"Create an application role in the app manifest for daemon access:
API_READ_APP_ROLE_ID="$(python - <<'PY'
import uuid
print(uuid.uuid4())
PY
)"Retrieve the current app manifest, append the role, and update the app registration:
az ad app show \
--id "$AAD_APP_CLIENT_ID" \
--query appRoles -o jsonThe resulting app registration must contain an app role equivalent to:
{
"allowedMemberTypes": ["Application"],
"description": "Allows daemon apps to read login events from the Flask API.",
"displayName": "API Read",
"id": "00000000-0000-0000-0000-000000000000",
"isEnabled": true,
"origin": "Application",
"value": "api_read"
}Implementation note:
- The exact CLI mechanics for patching
appRolescan vary over time. - Terraform should be preferred for repeatable role creation.
- If the portal is available, defining the app role there is acceptable.
DAEMON_APP_CLIENT_ID="$(az ad app create \
--display-name "$DAEMON_APP_NAME" \
--query appId -o tsv)"Create a client secret for the daemon:
DAEMON_APP_CLIENT_SECRET="$(az ad app credential reset \
--id "$DAEMON_APP_CLIENT_ID" \
--append \
--query password -o tsv)"Store the daemon secret in Key Vault:
az keyvault secret set \
--vault-name "$KEY_VAULT_NAME" \
--name "$DAEMON_APP_SECRET_NAME" \
--value "$DAEMON_APP_CLIENT_SECRET"Resolve the daemon service principal object ID:
DAEMON_APP_OBJECT_ID="$(az ad sp show \
--id "$DAEMON_APP_CLIENT_ID" \
--query id -o tsv)"Add the application permission:
az ad app permission add \
--id "$DAEMON_APP_CLIENT_ID" \
--api "$AAD_APP_CLIENT_ID" \
--api-permissions "<api-read-app-role-id>=Role"Grant admin consent:
az ad app permission admin-consent \
--id "$DAEMON_APP_CLIENT_ID"Implementation note:
- The placeholder
<api-read-app-role-id>is the GUID of theapi_readapp role on the App Service app registration. - Terraform should provision this grant directly for the repeatable path.
az webapp config appsettings set \
--resource-group "$RG" \
--name "$WEBAPP_NAME" \
--settings \
DASHBOARD_READ_APP_ROLE="$DASHBOARD_READ_APP_ROLE" \
DASHBOARD_WRITE_APP_ROLE="$DASHBOARD_WRITE_APP_ROLE" \
API_READ_APP_ROLE="$API_READ_APP_ROLE" \
SQL_SERVER_NAME="${SQL_SERVER_NAME}.database.windows.net" \
SQL_DATABASE_NAME="$SQL_DB_NAME" \
FLASK_SECRET_KEY="<generate-a-random-secret>" \
MICROSOFT_PROVIDER_AUTHENTICATION_SECRET="@Microsoft.KeyVault(VaultName=${KEY_VAULT_NAME};SecretName=${EASY_AUTH_SECRET_NAME})" \
SCM_DO_BUILD_DURING_DEPLOYMENT=trueEnable auth settings V2 and require authentication:
az webapp auth update \
--resource-group "$RG" \
--name "$WEBAPP_NAME" \
--enabled true \
--action LoginWithAzureActiveDirectoryConfigure the Microsoft identity provider:
az webapp auth microsoft update \
--resource-group "$RG" \
--name "$WEBAPP_NAME" \
--client-id "$AAD_APP_CLIENT_ID" \
--client-secret-setting-name MICROSOFT_PROVIDER_AUTHENTICATION_SECRET \
--tenant-id "$TENANT_ID" \
--issuer "https://sts.windows.net/${TENANT_ID}/" \
--yesAdd the API audience so daemon access tokens requested for the Application ID URI are accepted:
az resource update \
--resource-group "$RG" \
--resource-type "Microsoft.Web/sites/config" \
--name "${WEBAPP_NAME}/authsettingsV2" \
--set properties.identityProviders.azureActiveDirectory.validation.allowedAudiences='["'"$AAD_APP_CLIENT_ID"'","'"$AAD_APP_IDENTIFIER_URI"'"]' \
--set properties.globalValidation.excludedPaths='["/healthz"]'One simple option is ZIP deploy:
zip -r app.zip app.py requirements.txt templates
az webapp deploy \
--resource-group "$RG" \
--name "$WEBAPP_NAME" \
--src-path app.zip \
--type zipThis step must be executed while authenticated as the Microsoft Entra SQL admin.
The implementation configures database-level Microsoft Entra contained users only. It does not create server-level Microsoft Entra logins or assign server roles.
The web app managed identity must be included by default. Additional Microsoft Entra users, groups, managed identities, or service principals may be configured through sql_database_access in tfvars:
sql_database_access = {
app = {
principals = {
developers = {
name = "sg-app01-dev-sql-readers"
object_id = "00000000-0000-0000-0000-000000000000"
roles = ["db_datareader"]
}
}
}
reporting = {
name = "reporting-db"
principals = {
analysts = {
name = "sg-app01-reporting-readers"
object_id = "11111111-1111-1111-1111-111111111111"
roles = ["db_datareader"]
}
}
}
}For each configured database and principal, the SQL helper must run idempotent database-scoped SQL equivalent to:
CREATE USER [<webapp-name>] FROM EXTERNAL PROVIDER WITH OBJECT_ID = '<webapp-managed-identity-object-id>';
ALTER ROLE db_datareader ADD MEMBER [<webapp-name>];
ALTER ROLE db_datawriter ADD MEMBER [<webapp-name>];
ALTER ROLE db_ddladmin ADD MEMBER [<webapp-name>];Implementation note:
- The contained user name can still follow the web app name, but using
WITH OBJECT_ID = '<principal-id>'removes ambiguity when Microsoft Entra display names are duplicated or drift from the resource name. - If
WITH OBJECT_IDis not used, the contained user name should match the App Service managed identity service principal display name as resolved in Microsoft Entra. - In this repository,
infra/terraformmay optionally automate this step with alocal-exechelper when there is at least one effectivesql_database_accessprincipal. That helper still requires theterraform applyhost to havepython3,sqlcmd, network access to the SQL endpoint, and a Microsoft Entra-authenticated SQL admin context. - When that Terraform helper is enabled, the SQL server firewall must also allow the public egress IP of the
terraform applyhost.AllowAzureServicesonly covers Azure-originated traffic and does not cover a workstation or external runner. The Terraform configuration exposessql_firewall_allowed_ipv4_addressesfor this purpose. - The preferred Terraform entry point is
infra/terraform/environments/dev, which calls the reusableinfra/terraform/modules/app-stackmodule. - The environment wrapper should generate
infra/terraform/environments/dev/dev.envduringterraform applyso operational scripts can consume the deployment values without a separate redirection step.
az webapp browse \
--resource-group "$RG" \
--name "$WEBAPP_NAME"Request a token:
ACCESS_TOKEN="$(curl -sS -X POST \
"https://login.microsoftonline.com/${TENANT_ID}/oauth2/v2.0/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "client_id=${DAEMON_APP_CLIENT_ID}" \
--data-urlencode "client_secret=${DAEMON_APP_CLIENT_SECRET}" \
--data-urlencode "scope=${AAD_APP_IDENTIFIER_URI}/.default" \
--data-urlencode "grant_type=client_credentials" | jq -r '.access_token')"Call the API:
curl -sS \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
"https://${WEBAPP_NAME}.azurewebsites.net/api/logins"Equivalent Microsoft Entra and App Service portal steps:
- Open the App Service app registration in Microsoft Entra admin center.
- Go to
Expose an API. - Set the Application ID URI to
api://<easy-auth-client-id>unless a different approved URI is required. - Add an app role:
- Display name:
Dashboard Read - Allowed member types:
Users/Groups - Value:
dashboard_read - Description: a description that states the role allows approved users or groups to view the dashboard and read login events
- Display name:
- Add another app role:
- Display name:
Dashboard Write - Allowed member types:
Users/Groups - Value:
dashboard_write - Description: a description that states the role allows approved users or groups to clear dashboard login rows
- Display name:
- Add another app role:
- Display name:
API Read - Allowed member types:
Applications - Value:
api_read - Description: a description that states the role allows daemon access to the login events API
- Display name:
- Assign the
dashboard_readrole to the approved reader security group or users. - Assign the
dashboard_writerole to the approved admin security group or users. - Create a new app registration for the daemon client.
- Create a client secret or upload a certificate for the daemon client.
- On the daemon app registration, go to
API permissions. - Add a permission:
My APIs- select the App Service app registration
- choose
Application permissions - select
api_read
- Grant admin consent for the tenant.
- Open the App Service Authentication blade in Azure portal.
- Edit the Microsoft identity provider settings if needed and make sure the App Service app registration is the same one that exposes the API.
- Add the Application ID URI to the allowed token audiences if it is not already accepted.
Portal recommendation:
- For production daemon clients, prefer a certificate credential over a client secret.
- For this sample, a client secret is acceptable because the focus is implementation simplicity.
The implementation is complete only when all of the following are true:
- Visiting the site while anonymous causes a Microsoft sign-in flow.
- After sign-in, a user with
dashboard_readordashboard_writereaches the dashboard successfully. - The dashboard shows the signed-in user's identity details.
- Authenticated
GET /api/loginsby a user withdashboard_readordashboard_writereturns recent login rows as JSON. - Authenticated
GET /api/loginswith a daemon application token that containsapi_readreturns recent login rows as JSON. - Authenticated
GET /dashboardwith an application token is rejected. - Authenticated
GET /dashboardby a user withoutdashboard_readordashboard_writeis rejected. - Authenticated
POST /dashboard/logins/clearby a user withoutdashboard_writeis rejected. - Authenticated
POST /dashboard/logins/clearby a user withdashboard_writeclears the table and redirects back to/dashboard. - The app creates
dbo.user_loginsautomatically if it does not exist. - The app inserts a login row for a newly authenticated browser session.
- The app inserts an application login row when an authorized daemon calls
GET /api/logins. - The dashboard shows recent login rows ordered from newest to oldest.
- The JSON API returns the same recent rows ordered from newest to oldest.
- A daemon client can acquire a client-credentials access token for the App Service API Application ID URI.
- No SQL username/password is stored in the app configuration.
- The app uses the App Service system-assigned managed identity for Azure SQL access.
- The Azure SQL Database is provisioned with the free-offer configuration instead of the legacy
Basicservice objective.
These choices are intentional for the sample implementation:
- The dashboard is server-rendered HTML, not a SPA.
- Login tracking is session-based, not a globally deduplicated audit stream.
- The app can create its own table at runtime.
- Public internet access plus Easy Auth is acceptable for the sample.
- Easy Auth header formats are platform-provided; do not hardcode assumptions beyond the documented base64 JSON principal contract.
- Azure SQL access through managed identity requires both server-level Entra setup and database-level user creation. Both are necessary.
- If ODBC Driver 18 is not present in the chosen App Service image, deployment will fail until the runtime environment includes it.
- Some Azure CLI auth commands rely on the
authV2extension. If the CLI prompts to install an extension, allow it. - The exact display name used by the managed identity in
CREATE USER ... FROM EXTERNAL PROVIDERmust match the Entra service principal identity visible to Azure SQL. - Microsoft documents the free-offer database as production-quality infrastructure but without an SLA while it remains in the free amount; this sample should therefore be treated as a dev/test or proof-of-concept baseline rather than a production database sizing recommendation.
- When the free-limit exhaustion behavior is
AutoPause, the database can become unavailable for the remainder of the calendar month after the free allowance is consumed. That is acceptable for this sample because the goal is lowest-cost provisioning.
- Anonymous request to
/dashboardredirects to sign-in. - Authenticated request to
/dashboardwithdashboard_readreturns HTTP 200. - Authenticated request to
/dashboardwithoutdashboard_readordashboard_writereturns HTTP 403. - Application-principal request to
/dashboardreturns HTTP 403. - User-principal request to
/dashboard/logins/clearwithoutdashboard_writereturns HTTP 403. - User-principal request to
/dashboard/logins/clearwithdashboard_writeredirects back to/dashboard. - Anonymous request to
/api/loginsreturns HTTP 401 JSON. - Authenticated request to
/api/loginswithdashboard_readreturns HTTP 200 JSON. - Authenticated request to
/api/loginswithoutdashboard_readordashboard_writereturns HTTP 403 JSON. - Application-principal request to
/api/loginswithoutapi_readreturns HTTP 403 JSON. - Application-principal request to
/api/loginswithapi_readreturns HTTP 200 JSON. - First authenticated request in a new browser session inserts one login row.
- Refreshing
/dashboardin the same session does not insert another row. - Recent rows query returns newest first.
/healthzreturns HTTP 200 only when the application can reach Azure SQL, otherwise HTTP 503.