Managing Service Accounts for Automated Permit Clerks
Managing service accounts means giving automation — the nightly importer, the notice generator, the status-sync bot — its own scoped, short-lived, revocable identity instead of borrowing a human clerk’s login. This guide extends Implementing Role-Based Access for Clerk Portals, part of the Core Architecture & Code Taxonomy for Municipal Permits track, to the non-human actors that now do most of the permit-workflow heavy lifting.
Permalink to this section The Focused Problem: Non-Human Identity That Stays Least-Privilege
Automation in a permit pipeline acts on records constantly — a batch job ingests overnight submissions, a scheduler advances files whose review clock expired, a reporting worker reads violation counts. Each of those needs an identity the access layer can evaluate. The tempting shortcut is to let the job authenticate as a real clerk, reusing a person’s credentials in a cron entry. That single decision breaks every guarantee the RBAC layer was built to give: the audit trail now attributes machine actions to a human who was asleep, the credential never expires because a person’s session policy assumes interactive rotation, and the scope is a human’s full authority when the job needs to touch three fields.
The concrete failure mode is a broad, immortal, mis-attributed credential. A service key with permits:write sits in a config file for three years, is never rotated, appears in the audit log as [email protected], and — because nobody remembers the automation still runs — cannot be safely revoked. When it leaks, the blast radius is the human’s entire scope set, and the forensic trail points at an innocent employee. Compliance officers reviewing an incident cannot separate what the person did from what the bot did.
The input to this subsystem is an automation workload plus the minimal set of capabilities it genuinely needs. The output is a distinct, named service identity bound to a rotating credential, issued short-lived tokens carrying only its least-privilege scopes, revocable in one action, and stamped into the audit log in a way that is unmistakably a bot — never confused with a person.
Permalink to this section Step 1 — Register the Service Account as a Distinct, Scoped Identity
A service account is not a user with a funny name; it is a separate identity class with its own least-privilege scope set, an owning team, and an explicit purpose. Model it so the access layer can reason about it exactly as it does a clerk role, and so an auditor can see at a glance which human team is accountable for a bot.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
@dataclass(frozen=True, slots=True)
class ServiceAccount:
"""A non-human identity for one automation workload."""
name: str # "svc-nightly-import" — never a person's handle
purpose: str # human-readable reason it exists
owner_team: str # accountable team, e.g. "permits-platform"
scopes: frozenset[str] # least-privilege capability set
created_at: datetime = field(default_factory=lambda: datetime.now(tz=timezone.utc))
# One account per workload, each with only the scopes that workload exercises.
NIGHTLY_IMPORT = ServiceAccount(
name="svc-nightly-import",
purpose="ingest overnight CSV/PDF permit submissions",
owner_team="permits-platform",
scopes=frozenset({"permits:create", "permits:read"}), # no write/approve/delete
)
Grant one account per workload, not one shared “automation” account for everything — a shared identity re-creates the broad-blast-radius problem and makes the audit trail ambiguous about which job acted. The scope set here derives from the same source of truth as the clerk role matrix in the parent guide, so external, internal, and machine access never drift apart.
Permalink to this section Step 2 — Issue Short-Lived Tokens via the Client-Credentials Grant
Service accounts authenticate machine-to-machine with the OAuth 2.0 client-credentials grant: the account presents its signing key, and the token authority returns a short-lived JWT carrying only that account’s scopes. Cap the lifetime tightly — a machine can refresh silently, so there is no usability cost to a fifteen-minute token, and it caps the blast radius of a leak.
import jwt # PyJWT
from datetime import datetime, timedelta, timezone
TOKEN_TTL = timedelta(minutes=15) # short: automation refreshes without friction
ISSUER = "https://idp.internal/realms/permits"
AUDIENCE = "permit-api"
def mint_service_token(account: ServiceAccount, signing_key: str, kid: str) -> str:
now = datetime.now(tz=timezone.utc)
claims = {
"sub": account.name,
"actor_type": "service", # marks the token as non-human
"scope": " ".join(sorted(account.scopes)),
"iss": ISSUER,
"aud": AUDIENCE,
"iat": now,
"exp": now + TOKEN_TTL,
}
# Asymmetric signing; the verifier only holds the public key.
return jwt.encode(claims, signing_key, algorithm="RS256",
headers={"kid": kid})
The API validates these tokens with the identical evaluator used for clerk and vendor requests — same aud/iss/exp enforcement described in securing municipal API endpoints for third-party integrations — so a service token that strays outside its scopes is denied by the same gate that guards everything else.
Permalink to this section Step 3 — Store Secrets Outside the Codebase and Rotate on a Schedule
The signing key is the account’s whole identity; if it lands in a repo, a build log, or an unencrypted config file, the account is compromised. Load keys from a secrets manager at runtime, reference them by ID, and never let the raw material touch source control. Rotation runs on a schedule with an overlap window so in-flight tokens keep validating while the new key takes over.
import os
def load_signing_key(account_name: str) -> tuple[str, str]:
"""Fetch (key_id, private_key) from the secrets backend at runtime.
Never read a key from a checked-in file. The reference in config is only
the secret's *name*; the material lives in the vault / secrets manager."""
secret_name = os.environ["SIGNING_KEY_SECRET"] # e.g. "permits/svc-nightly-import"
kid, private_key = _secrets_client().fetch(secret_name) # vault SDK call
return kid, private_key
def rotate(account: ServiceAccount, overlap: timedelta = timedelta(hours=24)) -> None:
"""Publish a new key alongside the old one; both verify during `overlap`,
then the old key is retired. No request fails mid-rotation."""
new_kid, new_key = _secrets_client().generate_keypair(account.name)
_publish_public_key(new_kid) # verifier now trusts both kids
_secrets_client().set_active(account.name, new_kid) # new tokens use the new key
_secrets_client().schedule_retire(previous_active(account.name), after=overlap)
Rotate on a fixed interval regardless of whether you suspect exposure — regular rotation limits how long any single leaked key is useful, and the overlap window means the nightly job never wakes to a rejected token because the key rolled at midnight.
Permalink to this section Step 4 — Revoke Cleanly and Stamp Bot Actions in the Audit Log
Two operational must-haves distinguish a governed service account from a stray key: instant revocation, and an audit trail that never lets a bot masquerade as a person.
import hashlib
import json
def revoke(account_name: str) -> None:
"""Remove the active key so no new tokens can be minted. Existing tokens
expire within TOKEN_TTL; for immediate cutoff, also deny the sub at the gate."""
_secrets_client().delete_active(account_name)
_denylist_add(account_name) # gate rejects the sub until re-enabled
def audit_service_call(account_name: str, scopes: frozenset[str], route: str,
status_code: int, prev_hash: str) -> str:
entry = {
"actor": account_name,
"actor_type": "service", # the field that keeps bot != human
"scopes": sorted(scopes),
"route": route,
"status": status_code,
"prev_hash": prev_hash,
}
body = json.dumps(entry, sort_keys=True, separators=(",", ":")).encode()
return hashlib.sha256(body).hexdigest()
The actor_type: "service" tag is what lets an investigator filter the ledger to machine activity, and it feeds the same hash-chained trail described in auditing permission changes in clerk portals, so a service account’s grants and revocations are as attributable as a person’s.
Permalink to this section Parameter and Flag Reference
| Setting | Type | Default | Municipal-context notes |
|---|---|---|---|
token_ttl_minutes |
int |
15 |
Short by design; machines refresh silently, so there is no usability cost. |
signing_algorithm |
str |
"RS256" |
Asymmetric; the verifier holds only the public key. Never none or HS*. |
rotation_interval_days |
int |
30 |
Rotate on schedule regardless of suspected exposure; limits leaked-key lifetime. |
rotation_overlap_hours |
int |
24 |
Both old and new keys verify during the window so no request fails mid-rotation. |
scopes |
frozenset[str] |
frozenset() |
Start empty and add only what the workload exercises; never inherit a human’s set. |
secret_backend |
str |
"vault" |
Runtime secrets manager; keys must never enter source control or build logs. |
one_account_per_workload |
bool |
True |
Distinct account per job keeps the audit trail unambiguous and blast radius small. |
Permalink to this section Common Failure Patterns and Fixes
Permalink to this section Automation runs as a human clerk
A cron job authenticates with an employee’s credentials, so the audit log attributes machine actions to a sleeping person and the credential carries the human’s full scope. Register a dedicated service account with its own least-privilege scopes and stamp actor_type: "service" on every call.
Permalink to this section The signing key is committed to the repo
A key in config, a .env tracked by git, or a build log is a permanent leak the moment it is pushed. Reference secrets by name and fetch material from the vault at runtime; scan CI for accidental key strings and rotate immediately if one appears.
Permalink to this section One shared account for all bots
A single svc-automation used by five jobs makes the audit trail ambiguous and forces its scope set to the union of every job’s needs. Split into one account per workload so each carries only what it uses and each action is traceable to one job.
Permalink to this section Rotation with no overlap breaks the nightly job
Swapping the key atomically at midnight rejects any token minted seconds earlier. Publish the new key alongside the old, verify both during the overlap window, then retire the old one — the running job never sees a rejected token.
Permalink to this section A retired bot’s key is never revoked
An automation is decommissioned but its key lives on, a dormant broad credential nobody watches. Tie account lifecycle to workload lifecycle: revoke the key and deny-list the subject when the job is retired, and audit for accounts with no recent activity.
Permalink to this section Audit and Logging Guidance
Log every service-account action to the same append-only, hash-chained trail as human activity, always stamped with actor_type: "service" and the account name, scopes, route, and status — so an investigator can isolate machine activity in one filter and can never mistake a bot for a clerk. Record token issuance, every key rotation, and every revocation as their own auditable events, since a rotation or a new grant to a service account is exactly the administrative change tracked in auditing permission changes in clerk portals. Never persist raw signing keys or full token bodies; redact at ingestion. Keep the trail for the full state-mandated retention period, and review service accounts on a schedule for dormant identities and scope creep. Where automation feeds the ingestion pipeline, route its failures through error handling and retry logic for ingestion pipelines so a rejected batch is queued and traceable rather than silently dropped.
Permalink to this section Frequently Asked Questions
Permalink to this section Why not just give the automation a clerk login?
Because it breaks all three RBAC guarantees at once: the audit trail attributes machine actions to a human, the credential inherits a person’s full scope instead of the job’s minimal set, and it never rotates the way an interactive session assumes. A dedicated service account fixes attribution, scope, and lifecycle together.
Permalink to this section How short should service tokens really be?
Fifteen minutes or less. A machine refreshes silently through the client-credentials grant, so there is no usability penalty for a short lifetime, and a short exp means a leaked token is useless within minutes rather than valid until a manual revocation catches up.
Permalink to this section How do I tell bot actions apart from human ones in the audit log?
Stamp every service call with an actor_type: "service" claim carried in the token and written into the hash-chained audit entry. An investigator then filters the ledger by that field to isolate all machine activity, and no bot action can be mistaken for a clerk’s.
Permalink to this section Related
- Implementing Role-Based Access for Clerk Portals — the scope model these non-human identities plug into.
- Securing municipal API endpoints for third-party integrations — the same token evaluator applied to external callers.
- Auditing permission changes in clerk portals — where service-account grants and rotations are recorded.
- Core Architecture & Code Taxonomy for Municipal Permits — the parent track tying these subsystems together.