Hedronite · Ops Lesson · AKS + Rust + Nix · Wed 2026-10-21

AKS local accounts a Rust census shipped as a Nix overlay with a shellchecked wrapper

Entra sign-in is on, and --admin still hands out a certificate. Three fields say where; one flake check keeps the tool honest.

Lesson Class: Ops (AKS identity audit · Rust census · Nix overlay)
Tested with: cargo 1.101.0-nightly · rustc 1.98.1 · shellcheck 0.11.0 · Nix 2.35.2
Lab: cargo script · census crate + flake.nix · synthetic ARM fixtures · real transcripts in out/
Source: Microsoft Learn (AKS, ARM REST) · Cargo Book · Nixpkgs and Nix manuals · flake-parts docs
Absent Means Enabled
No disableLocalAccounts key is the default state, and the default is on.
Fix the Way In First
Give a cluster an admin path before you close the back door.
Check What You Ship
The overlay, the wrapper and the exit codes are all built and tested by nix flake check.
Every sign-in went through Entra. The certificate never asked.

The problem

A platform team runs several Azure Kubernetes Service (AKS) clusters spread across resource groups. They turned on Microsoft Entra ID sign-in and assume every kubectl call is now tied to a named person and recorded in an audit trail. Microsoft's documentation says otherwise: "When you deploy an AKS cluster, local accounts are enabled by default. Even when you enable RBAC or Microsoft Entra integration, --admin access still exists as a non-auditable backdoor option." Anyone allowed to run az aks get-credentials --admin receives a certificate-based kubeconfig that never touches Entra.

The team wants three things: a list of the clusters that still allow this, a warning about which clusters would lock everybody out if local accounts were switched off today, and a tool other teams can install from Nix without copying a script between repositories. Until then, a sign-in policy that looks complete on paper leaves one unaudited door open on every cluster nobody checked.

The idea, in plain words

Azure Resource Manager (ARM), the management API served at management.azure.com, returns every AKS cluster in a subscription as one JSON (JavaScript Object Notation) document. Three fields in that document answer the question: properties.disableLocalAccounts, properties.aadProfile, and properties.enableRBAC. A small Rust program reads the document, prints one line per problem, and exits with a code that says what happened. Nix builds that program, wraps it in a shell command that shellcheck (a static analyser for shell scripts) has verified, and publishes both as an overlay that other Nix projects can add to their package set. nix flake check then runs the whole chain against fixture files on every change, so a broken program, wrapper, or overlay fails before anyone installs it.

The three fields that decide who can get in

AKS is Azure's managed Kubernetes service. A kubeconfig is the file that tells kubectl which cluster to call and which credential to present. Microsoft Entra ID is Microsoft's identity service, formerly Azure Active Directory, which is why the API still says aad in field names. The API documents each field in plain terms:

Azure RBAC for Kubernetes authorization means Azure role assignments, such as "Azure Kubernetes Service RBAC Cluster Admin", decide what a signed-in user may do inside the cluster. With local accounts disabled, the admin command fails with "Operation failed with status: 'Bad Request'. Details: Getting static credential isn't allowed because this cluster is set to disable local accounts." The documentation adds a warning: on clusters without Entra integration and with the field set to true, "any attempt to authenticate with user or admin credentials will fail." Entra's own guide says "Make sure to set your admin group to keep access on your cluster." Switching the flag without an admin path in place therefore creates an outage, so the census reports four findings:

CodeConditionWhy it matters
LOCAL_ACCOUNTS_ONdisableLocalAccounts is false or absentThe certificate kubeconfig bypasses Entra and is not auditable per person
LOCKED_OUTLocal accounts disabled, no managed EntraEvery user and admin credential fails
NO_ADMIN_PATHManaged Entra, Azure RBAC off, no admin groupsNobody maps to cluster-admin
K8S_RBAC_OFFenableRBAC is falseKubernetes Role-Based Access Control (RBAC) is not enforcing permissions

The fixture file fixtures/clusters.json holds five synthetic clusters inside a {"value": [...]} envelope, shaped like the documented list response. One of them, verbatim:

{
  "name": "pay-staging",
  "id": "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-pay/providers/Microsoft.ContainerService/managedClusters/pay-staging",
  "type": "Microsoft.ContainerService/managedClusters",
  "location": "eastus",
  "properties": {
    "kubernetesVersion": "1.33",
    "enableRBAC": true,
    "disableLocalAccounts": false,
    "aadProfile": {
      "managed": true,
      "adminGroupObjectIDs": [
        "aaaaaaaa-0000-1111-2222-bbbbbbbbbbbb"
      ],
      "tenantID": "aaaabbbb-0000-cccc-1111-dddd2222eeee"
    }
  }
}

pay-staging has Entra integration and an admin group, yet disableLocalAccounts is explicitly false, so the admin certificate still works there. legacy-batch carries only "properties": {"kubernetesVersion": "1.32", "enableRBAC": false}: no aadProfile, no disableLocalAccounts key at all, and Kubernetes RBAC off. The resource group is not a field of its own: the census reads it from the id path, the segment after resourceGroups, so a finding can name rg-pay/pay-staging.

Symptom: a cluster whose JSON has no disableLocalAccounts key looks like it has nothing to report.

Cause: the sample list response on Microsoft's REST page omits the key entirely, and the documentation says local accounts are enabled by default. A missing key is the default state, which is "enabled".

Fix: treat absent as false, as the census does. It prints disableLocalAccounts absent: local accounts are enabled by default for legacy-batch. One finding can still be deliberate: Microsoft's break-glass procedure re-enables local accounts during an Entra outage and says to "disable local accounts again once Microsoft Entra sign-in is restored", so check the incident log before you file a LOCAL_ACCOUNTS_ON against a cluster that was recently in trouble.

The census as a cargo script

A cargo script is a single .rs file that Cargo, Rust's build tool, can run directly. The Cargo Book gives the form cargo +nightly -Zscript file.rs and says the file may carry package metadata in a "---cargo" frontmatter fence at the top. The feature is nightly-only and unstable, so its details may change. That makes it a good way to try a tool and a poor foundation for shipping one, which is why the later sections move to a normal crate. The script begins like this:

#!/usr/bin/env -S cargo +nightly -Zscript
---cargo
[package]
edition = "2024"

[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
---
use serde::Deserialize;

#[derive(Deserialize)]
struct ListResponse {
    value: Vec<ManagedCluster>,
    #[serde(rename = "nextLink")]
    next_link: Option<String>,
}

#[derive(Deserialize)]
struct AadProfile {
    managed: Option<bool>,
    #[serde(rename = "enableAzureRBAC")]
    enable_azure_rbac: Option<bool>,
    #[serde(rename = "adminGroupObjectIDs", default)]
    admin_group_object_ids: Vec<String>,
}

The fence declares the edition and two dependencies: serde, a Rust library that turns data formats into typed structs, and serde_json. Without an explicit edition, Cargo picks the current one and warns, and the package name defaults to the file name, so the script needs no [package] name. The API mixes naming styles (enableRBAC, enableAzureRBAC, adminGroupObjectIDs), and serde's rename_all = "camelCase" cannot produce those capitalisations, so each one gets an explicit rename. Every field is an Option or has a default, because ARM omits keys freely and a missing key must not abort the whole run.

The findings function applies the table's four rules, each returning a code and a message: absent or false disableLocalAccounts gives LOCAL_ACCOUNTS_ON, true without managed Entra gives LOCKED_OUT, managed Entra with Azure RBAC not true and no admin groups gives NO_ADMIN_PATH, and false enableRBAC gives K8S_RBAC_OFF.

The exit codes form a contract that a continuous integration (CI) job can gate on: 0 clean, 1 unreadable input, 2 findings, 3 incomplete page. Distinct codes let a pipeline tell "the audit found problems" apart from "the audit could not run", so a broken input never passes as a clean report. The four runs, in order:

$ cargo +nightly -q -Zscript aks-local-accounts.rs < census/fixtures/clusters.json
FINDING rg-pay/pay-staging LOCAL_ACCOUNTS_ON: disableLocalAccounts=false: az aks get-credentials --admin returns a certificate that bypasses Entra ID
FINDING rg-batch/legacy-batch LOCAL_ACCOUNTS_ON: disableLocalAccounts absent: local accounts are enabled by default
FINDING rg-batch/legacy-batch K8S_RBAC_OFF: enableRBAC=false: Kubernetes RBAC is off
FINDING rg-edge/edge-dev LOCKED_OUT: local accounts disabled without managed Entra integration: every user and admin credential fails
FINDING rg-ml/ml-train NO_ADMIN_PATH: Entra on, Azure RBAC off, adminGroupObjectIDs empty: nobody is mapped to cluster-admin
5 cluster(s), 5 finding(s)
[exit 2]
$ cargo +nightly -q -Zscript aks-local-accounts.rs < census/fixtures/clean.json
1 cluster(s), 0 finding(s)
[exit 0]
$ cargo +nightly -q -Zscript aks-local-accounts.rs < census/fixtures/paged.json
1 cluster(s), 0 finding(s)
INCOMPLETE: response has nextLink; fetch every page before trusting a clean result
[exit 3]
$ cargo +nightly -q -Zscript aks-local-accounts.rs < census/fixtures/bare-array.json
error: not a managedClusters list response: invalid type: map, expected a sequence at line 2 column 2
[exit 1]

The first run reports one finding per problem, and legacy-batch appears twice because it fails two rules. pay-prod produces nothing, which is correct: local accounts are disabled, Entra is managed, and Azure RBAC is on. The third run covers paging. The ARM list response carries nextLink, "The link to the next page of items", so one page with zero findings proves nothing about the clusters on later pages. Exit 3 forces the caller to fetch every page before trusting a clean result. The az commands themselves were not run, since there was no Azure account, so every run above read synthetic fixtures shaped like the documented response.

Symptom: feeding the tool a bare JSON array of clusters fails with invalid type: map, expected a sequence at line 2 column 2, which points at a map where a sequence is expected, the opposite of what the input looks like.

Cause: serde's derived struct deserializer also accepts a JSON array as the struct's fields in order. It treated the array as a ListResponse, took the first element, a cluster object, as the value field, and complained that an object is not a sequence.

Fix: feed the full ARM response, for example from az rest --method get --url ...managedClusters?api-version=2026-08-01, as the wrapper does, and not a reshaped list.

Packaging: flake-parts, buildRustPackage, and writeShellApplication

A Nix flake is a directory with a flake.nix that declares its inputs (pinned in flake.lock) and its outputs. A derivation is Nix's build recipe; its result lands in a directory under /nix/store whose name begins with a hash of the recipe. flake-parts is a library that structures a flake as modules, where perSystem defines each output once and the library repeats it for every listed system. The whole flake:

{
  description = "AKS local-accounts census: crate, overlay and shell wrapper, gated by nix flake check";

  inputs = {
    nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
    flake-parts.url = "github:hercules-ci/flake-parts";
  };

  outputs = inputs@{ flake-parts, ... }:
    flake-parts.lib.mkFlake { inherit inputs; } {
      imports = [ inputs.flake-parts.flakeModules.easyOverlay ];
      systems = [ "aarch64-darwin" "x86_64-linux" "aarch64-linux" ];

      perSystem = { config, pkgs, system, ... }: {
        packages.aks-local-accounts = pkgs.rustPlatform.buildRustPackage {
          pname = "aks-local-accounts";
          version = "0.1.0";
          src = pkgs.lib.fileset.toSource {
            root = ./.;
            fileset = pkgs.lib.fileset.unions [ ./Cargo.toml ./Cargo.lock ./src ./fixtures ];
          };
          cargoLock.lockFile = ./Cargo.lock;
        };

        # The operator-facing command. runtimeInputs puts the census on PATH;
        # az comes from the caller's PATH (inheritPath defaults to true).
        packages.aks-local-accounts-audit = pkgs.writeShellApplication {
          name = "aks-local-accounts-audit";
          runtimeInputs = [ config.packages.aks-local-accounts ];
          text = ''
            if [ "$#" -ge 1 ]; then
              aks-local-accounts < "$1"
            else
              sub=$(az account show --query id --output tsv)
              az rest --method get \
                --url "https://management.azure.com/subscriptions/$sub/providers/Microsoft.ContainerService/managedClusters?api-version=2026-08-01" \
                | aks-local-accounts
            fi
          '';
        };

        packages.default = config.packages.aks-local-accounts-audit;

        # easyOverlay turns these into overlays.default = final: prev: { ... }.
        overlayAttrs = {
          inherit (config.packages) aks-local-accounts aks-local-accounts-audit;
        };

        checks.package = config.packages.aks-local-accounts;

        checks.fixtures = pkgs.runCommand "aks-fixtures" { } ''
          audit=${config.packages.aks-local-accounts-audit}/bin/aks-local-accounts-audit
          expect() {
            want=$1; shift
            set +e; "$audit" "$@" > report.txt 2>&1; got=$?; set -e
            cat report.txt
            echo "[exit $got, want $want]"
            test "$got" -eq "$want"
          }
          expect 2 ${./fixtures/clusters.json}
          grep -q '5 cluster(s), 5 finding(s)' report.txt
          expect 0 ${./fixtures/clean.json}
          expect 3 ${./fixtures/paged.json}
          expect 1 ${./fixtures/bare-array.json}
          touch $out
        '';

        # A consumer that knows nothing about this flake except its overlay.
        checks.overlay-consumer =
          let
            consumer = import inputs.nixpkgs {
              inherit system;
              overlays = [ inputs.self.overlays.default ];
            };
          in
          consumer.runCommand "overlay-consumer" { } ''
            ${consumer.aks-local-accounts-audit}/bin/aks-local-accounts-audit ${./fixtures/clean.json} | tee $out
          '';
      };
    };
}

The first package uses buildRustPackage, nixpkgs' Rust builder. With cargoLock.lockFile it "will retrieve the dependencies using fixed-output derivations from the specified lockfile", so every crate is fetched by hash from the versions recorded in Cargo.lock, and the build log confirms it with a line starting Validating consistency between the source lock file and the vendored one. Its check phase "runs cargo test" in release mode by default, so the crate's own tests gate the build, and the build log shows them passing: running 2 tests, then test result: ok. 2 passed; 0 failed. The second package uses writeShellApplication, which writes the script to bin/<name> and "checks its syntax with shellcheck and the bash's -n option". A runtimeInputs entry goes on the script's PATH, and because inheritPath defaults to true, az comes from the caller. The generated head and the dependency closure (the set of store paths the wrapper needs at runtime) show the result:

#!/nix/store/f0b9dbw2gl596cm03pvdsss72381qhlp-bash-5.3p15/bin/bash
set -o errexit
set -o nounset
set -o pipefail

export PATH="/nix/store/cimicqrf7js6cpqalf72sgxj7a4bch7l-aks-local-accounts-0.1.0/bin:$PATH"

The shebang points at one specific Bash in the store, so the wrapper never depends on whichever shell the host happens to have. The three set -o lines turn on errexit (stop on any failing command), nounset (error on unset variables), and pipefail (a failure anywhere in a pipeline fails the whole pipeline). The export PATH line is runtimeInputs at work: the census directory is prepended, and the caller's PATH stays after it. nix path-info -r lists the closure:

$ nix path-info -r .#aks-local-accounts-audit | sed "s|/nix/store/[a-z0-9]*-||"
aks-local-accounts-0.1.0
bash-5.3p15
aks-local-accounts-audit

The closure holds exactly three paths: the census, Bash, and the wrapper. The Azure CLI is absent from it, so the wrapper uses whatever signed-in az the caller already has; the trade-off is that with no az on PATH the live mode stops at the first command, because errexit is on.

Symptom: the build of the wrapper fails with builder failed with exit code 1 after a harmless-looking edit.

Cause: the wrapper text contained aks-local-accounts < $1 with the variable unquoted. Shellcheck reported SC2086 (info): Double quote to prevent globbing and word splitting. and suggested aks-local-accounts < "$1". The rule is only "info" severity, and in this run it still failed the build.

Fix: quote it, as shellcheck suggests. An unquoted path with a space would otherwise split into two arguments at runtime.

The overlay, and a nix flake check that proves it

An overlay is a function of two arguments, conventionally final and prev, that returns a set of packages. The nixpkgs manual says final is "the final package set" and should be used "for the dependencies of all packages specified in your overlay". It also says prev holds the result of the earlier stages and "does not contain any of the packages added by the current overlay". A consumer passes it when importing nixpkgs: import <nixpkgs> { overlays = [ overlay1 overlay2 ]; }. flake-parts' easyOverlay module derives overlays.default from the overlayAttrs set in perSystem. In that context the pkgs argument is the overlay's prev, so the tool is built against the consumer's nixpkgs and not a private copy. flake-parts notes that overlays are not defined under a system attribute but "in a top level attribute".

nix flake check is documented as "check whether the flake evaluates and run its tests". It requires overlays.default to be a Nixpkgs overlay, and it builds the checks for the current system. Three checks guard this flake: package builds the crate and runs its unit tests, fixtures runs the wrapper over four fixtures and compares each exit code with the expected one, and overlay-consumer imports nixpkgs with only the overlay and runs the clean fixture through the tool it finds there. The green run, trimmed:

✅ overlays.default
✅ packages.aarch64-darwin.aks-local-accounts-audit (build skipped)
aks-fixtures> [exit 2, want 2]
aks-fixtures> [exit 0, want 0]
aks-fixtures> [exit 3, want 3]
aks-fixtures> [exit 1, want 1]
✅ checks.aarch64-darwin.package
✅ checks.aarch64-darwin.fixtures
✅ checks.aarch64-darwin.overlay-consumer
warning: The check omitted these incompatible systems: aarch64-linux, x86_64-linux
Use '--all-systems' to check all.
[exit 0, 8 s]

Notice (build skipped) on the package line: nix flake check only evaluates packages, which is why the crate is listed again as checks.package, so its tests actually run. The [exit 2, want 2] lines come from the expect helper in the flake, which runs the wrapper against a fixture and fails the check if the code differs. The warning says only the current system was checked. Run unchanged a second time, the same command reported each check as (previously built) and finished in about a second, because nothing in the inputs had changed. The overlay consumer imports the same pinned nixpkgs as the flake, so it receives the identical store path, and nothing was built twice:

{ flakePkg = "/nix/store/cimicqrf7js6cpqalf72sgxj7a4bch7l-aks-local-accounts-0.1.0"; same = true; viaOverlay = "/nix/store/cimicqrf7js6cpqalf72sgxj7a4bch7l-aks-local-accounts-0.1.0"; }

A check is only useful if it fails when it should, so the lab changed clean.json to give its one cluster "disableLocalAccounts": false and ran nix flake check -L.

Symptom: the check fails, and only one of the two broken checks is reported:

❓ checks.aarch64-darwin.fixtures (cancelled)
❌ checks.aarch64-darwin.overlay-consumer
error: Cannot build '/nix/store/f6war83f9v2wmn7knl8gjlkzf3xkz8yf-overlay-consumer.drv'.
       Reason: builder failed with exit code 2.

Cause: without --keep-going the first failure cancels the remaining builds, so fixtures never finished. The exit code 2 is the census's own "findings" code. It survived the | tee $out in the check's command, which means the build shell treated the failed left side as a failed pipeline instead of reporting tee's success. Do not rely on that in your own scripts without testing it; this run is the evidence here.

Fix: rerun with --keep-going, which the manual describes as continuing to evaluate "as much as it can" and reporting errors as they arise. That run showed both failures, including the fixtures check's expected-versus-actual mismatch: aks-fixtures> [exit 2, want 0], followed by ❌ checks.aarch64-darwin.fixtures and ❌ checks.aarch64-darwin.overlay-consumer.

Putting it together

The loop starts in a shell where az is signed in. Run aks-local-accounts-audit with no arguments. The wrapper reads the subscription ID, calls ARM through az rest, and pipes the response to the census. That live path was not run here, because the lab had no Azure account. Fix clusters in a safe order. A LOCKED_OUT cluster needs Entra integration with an admin group (az aks update --enable-aad --aad-admin-group-object-ids ...) before anyone can sign in again, and a NO_ADMIN_PATH cluster needs an admin group or Azure RBAC, so that disabling local accounts cannot strand anyone. Then run az aks update --resource-group <resource-group> --name <cluster-name> --disable-local-accounts. Finally, rotate the cluster certificates, since the documentation says the administrator "must rotate the cluster certificates to revoke certificates they might have had access to". Rerun the census afterward and expect exit 0. Other teams add inputs.aks-census.overlays.default to the overlays list of their nixpkgs import and get both packages.

Tested with cargo 1.101.0-nightly for the script, cargo 1.99.0 for the tests, and nixpkgs fe13242 (rustc 1.98.1, shellcheck 0.11.0) with Nix 2.35.2. Verified: the cargo script against four fixtures, the unit tests, and the Nix builds and nix flake check on aarch64-darwin, including the two deliberate breakages. The Nix sandbox was off on that machine (sandbox = false), so the builds were not network-isolated. Not verified: any call to Azure, --all-systems, and any run on Linux, though the flake lists Linux systems.

Check yourself

Question 1
Why does the census treat a missing disableLocalAccounts key as a finding?
tap to reveal
Microsoft says local accounts are enabled by default, and the REST sample response omits the key. An absent key is therefore the enabled state.
Question 2
Which finding warns that disabling local accounts would cause an outage, and why?
tap to reveal
LOCKED_OUT for clusters without managed Entra, where every user and admin credential would fail. NO_ADMIN_PATH is the Entra-enabled cousin, where nobody maps to cluster-admin.
Question 3
Why does a clean result with exit code 3 not count as clean?
tap to reveal
The response carried nextLink, so more clusters sit on later pages. The tool exits 3 so a pipeline cannot mistake a partial scan for a full one.
Question 4
What does --keep-going change in nix flake check, and what did the lab show?
tap to reveal
Without it, the first failing check cancels the others. With it, every failure is reported, so the broken fixture showed both overlay-consumer and fixtures failing.
Question 5
Why is the Azure CLI absent from the wrapper's closure?
tap to reveal
runtimeInputs lists only the census, and inheritPath defaults to true, so az is taken from the caller's PATH at runtime.

Sources