Ship application audit and security events into write-once, read-many (WORM) blob storage, where they cannot be altered or deleted for a fixed retention period, using only Azure Monitor plumbing that is already there.
Bicep-provisioned, one command up, one command down. Includes a demo app that emits the events, so you can watch a record travel the whole path end-to-end.
Documentation: https://lukeevanstech.github.io/azure-immutable-audit-logs/
Application -> Application Insights -> Log Analytics -> Data Export -> Immutable blobs
TrackEvent workspace-based AppEvents ~5 min batches WORM, 6 years
The application calls TrackEvent. Everything after that is configuration - custom events land in AppEvents, which is a standard Log Analytics table and therefore exportable, so no application change is needed beyond the telemetry calls you would write anyway.
Plenty of systems log what users did. Rather fewer can prove the log has not been edited since.
If an audit trail exists to answer a question after the fact, then anyone with administrative access to the logging system is a hole in it: retention can be shortened, rows deleted, tables dropped, none of it leaving a mark. A locked immutable container closes that - the records cannot be modified or deleted by anyone for the retention period, including subscription owners and Microsoft support.
Prerequisites: Azure CLI (signed in), jq, zip, and the .NET 10 SDK if you want the demo app. mise install picks up the pinned versions.
git clone https://github.com/LukeEvansTech/azure-immutable-audit-logs.git
cd azure-immutable-audit-logs
./scripts/deploy.sh --resource-group rg-auditlogs-demo --location uksouthAdd --enable-auth to put Entra ID sign-in in front of the app, so events carry a real user identity.
Then open the app URL, generate some events, and check they landed:
./scripts/verify.sh --resource-group rg-auditlogs-demo \
--storage-account <storage-account> --workspace-guid <workspace-guid>When you are finished:
./scripts/teardown.sh --resource-group rg-auditlogs-demo --purge-workspaceExport takes around 30 minutes to provision before it writes anything. Events generated during that window may never reach storage. This is the single most common "it is broken" report, and it is not broken.
On Windows, or anywhere with PowerShell, every script has a PowerShell twin with the same behaviour - deploy.ps1, verify.ps1, lock-retention.ps1, teardown.ps1. Both Windows PowerShell 5.1 and PowerShell 7+ work. They take named parameters rather than flags:
./scripts/deploy.ps1 -ResourceGroup rg-auditlogs-demo -Location uksouth
./scripts/verify.ps1 -ResourceGroup rg-auditlogs-demo -StorageAccount <name> -WorkspaceGuid <guid>
./scripts/teardown.ps1 -ResourceGroup rg-auditlogs-demo -PurgeWorkspaceTwo entry points, sharing the same retention module.
infra/main.bicep |
infra/retention-only.bicep |
|
|---|---|---|
| For | Seeing it work end-to-end | Production |
| Log Analytics workspace | Created | Yours, untouched |
| Application Insights | Created | Untouched |
| Demo app | Created | Not deployed |
| WORM storage, containers, policies | Created | Created |
| Data Export rule | Created | Created |
retention-only.bicep adds the retention tier to a workspace you already own, creating nothing inside it beyond the export rule. It handles the workspace living in a different resource group or subscription from the storage account.
cp infra/retention-only.example.bicepparam infra/retention-only.bicepparam
# set workspaceResourceId, and location if the target RG is in another region
az deployment group create \
--resource-group <target-rg> \
--template-file infra/retention-only.bicep \
--parameters infra/retention-only.bicepparamSee the deployment guide for permissions, deriving the workspace from an Application Insights component, and adding tables later.
The retention tier deploys with its public endpoint disabled. A blob private endpoint, registered in a private DNS zone, is the only client route to it.
The private endpoint is not on the path the records travel. Data Export writes through the Azure Monitor platform, not as a network client of the account, so the endpoint governs who can read the archive rather than whether it fills. Both halves were confirmed on a live deployment: the blob service kept recording writes while a listing from outside was refused as blocked by network rules of storage account, with the caller holding Storage Blob Data Contributor at the time.
The cost is that you cannot read the archive from your own machine. That is the point, but it means the ordinary check stops working:
./scripts/verify-private.sh --resource-group <rg> \
--storage-account <name> --subnet <verifier-subnet-id>That creates a throwaway container inside the network, prints what it could see, and deletes itself.
Storage is fixed at TLS 1.2, because the resource provider rejects TLS1_3 with FeatureNotSupported despite it appearing in the ARM enum. App Service accepts 1.3 and defaults to 1.2.
- Containers are created with retention policies already attached, before the export rule exists. Letting export create its own containers and applying policies afterwards leaves a window where records land unprotected - and a later policy does not retrospectively cover them.
- Shared key access is off by default, so every read goes through Entra ID and appears in the blob access log with the reader's identity. With keys enabled, reads are recorded as anonymous.
- Telemetry sampling is disabled in the app. It is on by default in the SDK, and it drops a proportion of events without recording that it did. Right for performance monitoring, wrong for an archive whose value is completeness.
- Locking is a separate script with its own confirmation. Policies deploy unlocked. Locking is irreversible, and
scripts/lock-retention.shmakes you typeLOCK. teardown.shrefuses to run against locked policies, because the delete would fail part-way and strand the resource group.
./scripts/lock-retention.sh --resource-group <rg> --storage-account <storage-account>This cannot be undone. A locked policy cannot be removed, shortened or unlocked by anyone. The period can only be extended, and neither the storage account nor its resource group can be deleted until every blob has passed its retention - six years, on the default. Decide the retention period before locking, and never lock the demo.
Nothing locks anything automatically. Both templates deploy policies unlocked, and lock-retention.sh is the only thing that locks, interactively, after making you type LOCK.
Unlocked still protects the data, but never traps you. Under an active unlocked policy a blob delete is rejected with
BlobImmutableDueToPolicy, exactly as under a locked one - so the demo genuinely demonstrates immutability. Deleting the storage account, however, succeeds and takes the blobs with it. That asymmetry is the practical difference between the two: unlocked means you can always get rid of it, locked means you cannot until the retention period runs out.
| Path | Contents |
|---|---|
infra/ |
main.bicep, retention-only.bicep, shared modules/, example parameters |
app/ |
.NET 10 demo app and its test console UI |
scripts/ |
deploy, verify, lock-retention, teardown - each as .sh and .ps1 |
docs/ |
Documentation site source (Zensical) |
CI builds both Bicep templates and both parameter files, compiles and publishes the app with warnings as errors, runs ShellCheck over the scripts, and builds the docs site.
There is no unit test suite: the app is a telemetry emitter with no logic worth isolating, and the meaningful verification is end-to-end, which is what scripts/verify.sh does against a live deployment. It checks container protection, blob arrival, and the KQL surfaces, and optionally attempts a delete that must fail.
Blob storage grows without bound for the retention period, because nothing can be deleted - six years of a busy application is the number to model before locking anything. On top of that: Log Analytics ingestion at your workspace rate, blob diagnostics (which are themselves ingested, and never go quiet because export writes every five minutes), and for the demo, a B1 App Service plan.
MIT. See LICENSE.