Mapping NIST SP 800-53 controls to tower data access
An auditor does not ask whether your system “has RBAC” — they ask you to point at the specific control, AC-6 say, and show the line of code, the role definition, or the audit field that satisfies it. Without that mapping, every compliance review starts from zero: someone re-derives, from scratch, which of your ten role permissions actually implement least privilege and which are just permissions that happen to exist. This page builds that mapping directly, as data and as code, inside the Regulatory Source Crosswalk that ties every external regulatory citation to an internal implementation. It picks up where Configuring RBAC for telecom infrastructure data leaves off — that page builds the evaluator; this one proves, control by control, which NIST SP 800-53 requirements the evaluator actually satisfies, and which ones it does not.
Five controls carry the weight of access governance over telecom tower compliance data: AC-2 (Account Management), AC-3 (Access Enforcement), AC-6 (Least Privilege), and the pair AU-2 / AU-3 (Audit Events and Content of Audit Records). Each maps to something concrete — a role definition, a field-level restriction, an audit-log schema field — and none of them are satisfied by intention alone. The goal here is a runnable control-coverage report: given a role model, which controls does it demonstrably meet, and where is the gap that a reviewer will find before you do.
Prerequisites & Context
You need Python 3.10 or newer; everything below runs on the standard library — hashlib, dataclasses, logging, and the built-in set/dict types — with no third-party dependencies. Before mapping controls to code, three things should already be in place:
- A role-to-action model. This page evaluates roles like
field_techandlease_manageragainst control requirements; it assumes those roles are already expressed as explicit action sets, in the shape built out in Configuring RBAC for telecom infrastructure data. If your roles are still free-text strings rather than a closed vocabulary, build that first — a control mapping over an undefined vocabulary just relocates the ambiguity. - A canonical field list.
AC-6least-privilege checks below test whether a role can reachrent_amount_usd, one of the sensitive fields cataloged across the broader Telecom Compliance Data Reference, so the field names on each side of the check need to agree. - An audit-record schema.
AU-3asks what a compliance record contains, not just that one exists; you need a settled list of fields —actor,action,resource_id,timestamp,outcome— before you can check whether your logging actually emits them. If you also work with FCC filing identifiers, the neighboring Validating FCC ASR numbers in Python guide covers the sibling crosswalk forfcc_asr_numbervalidation under 47 CFR Part 17 — a useful pattern comparison, since both pages turn a regulatory citation into a runnable check.
Step-by-Step Implementation
Step 1 — Enumerate the target controls. Pin down the exact control identifiers in scope before writing any evaluation logic, so “we do access control” becomes five falsifiable claims instead of one vague one:
CONTROLS = {
"AC-2": "Account Management",
"AC-3": "Access Enforcement",
"AC-6": "Least Privilege",
"AU-2": "Audit Events",
"AU-3": "Content of Audit Records",
}
AC-2 and AC-3 govern who has an account and what it can reach; AC-6 governs how narrow that reach is; AU-2 and AU-3 govern what gets recorded when reach is exercised, and how completely. Treat the five as independent claims — a system can satisfy AC-3 (every request is checked against a role) while failing AC-6 (the role it checks against is too broad).
Step 2 — Map each control to a concrete implementation over tower data. AC-2 maps to how roles like field_tech and lease_manager are provisioned as explicit, closed action sets rather than open-ended grants — an account with an undefined action set is an AC-2 gap by definition, not a matter of degree. AC-3 maps to the RBAC evaluator’s per-request check: every action a caller attempts is compared against that caller’s role scope before anything executes, not just at login. AC-6 maps to field-level restriction — specifically, whether field_tech, a role that only ever needs read_structural_loads and write_maintenance_log, can reach read_financials and, through it, rent_amount_usd; if it can, the role is broader than its job requires. AU-2 maps to the set of event types your system actually emits — access_granted, access_denied, role_changed — versus the set NIST expects you to be able to produce on demand. AU-3 maps to the fields inside each emitted event: an audit line that logs action but drops actor or timestamp satisfies AU-2 (an event fired) while failing AU-3 (the event’s content is incomplete).
Step 3 — Evaluate a role model against the controls. Take the actual ROLE_MODEL — the live mapping of role name to permitted action set — and check it mechanically against each control’s requirement, rather than asserting compliance from a policy document. AC-2 passes if every role in the model has a non-empty, explicit action set. AC-3 passes if every action referenced anywhere in the model is owned by at least one role, so no action floats unassigned outside the scope the evaluator checks. AC-6 passes only if no role outside lease_manager can reach read_financials.
Step 4 — Emit a control-coverage report flagging gaps. Rather than a pass/fail for the whole system, produce one verdict per control, so a reviewer — or a CI gate — can see exactly which claim is unsupported:
def build_coverage_report(role_model):
all_actions = set().union(*role_model.values())
report = {}
for control_id in CONTROLS:
if control_id == "AC-2":
ok = all(actions for actions in role_model.values())
elif control_id == "AC-3":
ok = {"read_structural_loads", "write_maintenance_log",
"read_financials", "view_lease_terms"} <= all_actions
elif control_id == "AC-6":
ok = not any("read_financials" in a for r, a in role_model.items() if r != "lease_manager")
elif control_id == "AU-2":
ok = {"access_granted", "access_denied", "role_changed"} <= EMITTED_AUDIT_EVENTS
else:
ok = {"actor", "action", "resource_id", "timestamp", "outcome"} <= AUDIT_RECORD_FIELDS
report[control_id] = "COVERED" if ok else "GAP"
return report
In the runnable example below, this reports AU-2 as a GAP — the system emits access_granted and access_denied but not yet role_changed, so a provisioning change to field_tech today would leave no audit trail of the change itself, even though every read and write against tower data is still fully logged.
Step 5 — Seal the report with hashlib.sha256. A coverage report that can be silently edited after the fact is worthless to an auditor; hash the sorted control-to-verdict pairs into one digest, so any later dispute about “what did the report say on the day of the review” is resolved by regenerating the hash from the stored verdicts and comparing.
Complete Runnable Example
The diagram traces each control from its family, through its concrete implementation over tower data, to the coverage verdict the code below computes; the AU-2 row is deliberately shown as a gap so the diagram and the code output agree.
Figure: five NIST SP 800-53 controls, their tower-data implementation, and the coverage verdict the code below computes.
import hashlib
import logging
from typing import Dict, Set
logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger("nist_control_mapping")
class ControlMappingError(Exception):
"""Raised when a role definition violates the control it is checked against."""
def __init__(self, control_id: str, message: str):
self.control_id = control_id
super().__init__(message)
CONTROLS = {
"AC-2": "Account Management", "AC-3": "Access Enforcement",
"AC-6": "Least Privilege", "AU-2": "Audit Events", "AU-3": "Content of Audit Records",
}
ROLE_MODEL: Dict[str, Set[str]] = {
"field_tech": {"read_structural_loads", "write_maintenance_log"},
"lease_manager": {"read_financials", "view_lease_terms"},
}
EMITTED_AUDIT_EVENTS = {"access_granted", "access_denied"}
AUDIT_RECORD_FIELDS = {"actor", "action", "resource_id", "timestamp", "outcome"}
def enforce_least_privilege(role: str, actions: Set[str]) -> None:
if role != "lease_manager" and "read_financials" in actions:
raise ControlMappingError("AC-6", f"{role} exceeds least privilege: read_financials granted")
logger.info(f"AC-6 holds for {role}: {sorted(actions)}")
def build_coverage_report(role_model: Dict[str, Set[str]]) -> Dict[str, str]:
all_actions: Set[str] = set().union(*role_model.values())
report = {}
for control_id in CONTROLS:
if control_id == "AC-2":
ok = all(actions for actions in role_model.values())
elif control_id == "AC-3":
ok = {"read_structural_loads", "write_maintenance_log",
"read_financials", "view_lease_terms"} <= all_actions
elif control_id == "AC-6":
ok = not any("read_financials" in a for r, a in role_model.items() if r != "lease_manager")
elif control_id == "AU-2":
ok = {"access_granted", "access_denied", "role_changed"} <= EMITTED_AUDIT_EVENTS
else:
ok = {"actor", "action", "resource_id", "timestamp", "outcome"} <= AUDIT_RECORD_FIELDS
report[control_id] = "COVERED" if ok else "GAP"
return report
def seal_report(report: Dict[str, str]) -> str:
payload = ":".join(f"{k}={v}" for k, v in sorted(report.items()))
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
if __name__ == "__main__":
for role, actions in ROLE_MODEL.items():
enforce_least_privilege(role, actions)
coverage = build_coverage_report(ROLE_MODEL)
for control_id, status in coverage.items():
level = logging.WARNING if status == "GAP" else logging.INFO
logger.log(level, f"{control_id} ({CONTROLS[control_id]}): {status}")
digest = seal_report(coverage)
print(f"Coverage: {coverage}")
print(f"Report seal: {digest[:16]}...")
Verification & Expected Output
Save the block as control_mapping.py and run python3 control_mapping.py. With the ROLE_MODEL above — field_tech scoped to structural reads and maintenance writes, lease_manager scoped to financials and lease terms — you should see:
2026-07-17 ... | INFO | AC-6 holds for field_tech: ['read_structural_loads', 'write_maintenance_log']
2026-07-17 ... | INFO | AC-6 holds for lease_manager: ['read_financials', 'view_lease_terms']
2026-07-17 ... | INFO | AC-2 (Account Management): COVERED
2026-07-17 ... | INFO | AC-3 (Access Enforcement): COVERED
2026-07-17 ... | INFO | AC-6 (Least Privilege): COVERED
2026-07-17 ... | WARNING | AU-2 (Audit Events): GAP
2026-07-17 ... | INFO | AU-3 (Content of Audit Records): COVERED
Coverage: {'AC-2': 'COVERED', 'AC-3': 'COVERED', 'AC-6': 'COVERED', 'AU-2': 'GAP', 'AU-3': 'COVERED'}
Report seal: 284944c2f3dbe61e...
The AU-2 line downgrades to WARNING on purpose — a coverage report that logs every control at the same level buries the one line a reviewer needs to find. To watch AC-6 fail instead, add "read_financials" to field_tech’s action set: enforce_least_privilege now raises ControlMappingError, and if you catch it and re-run build_coverage_report, the AC-6 row flips to GAP because the not any(...) check now finds a role other than lease_manager holding read_financials. The seal digest changes accordingly — it is taken over the sorted control_id=status pairs, so any single flipped verdict produces a completely different hash, not a partial one.
Gotchas & Edge Cases
AC-2andAC-3look identical in code but answer different questions. The example’sAC-2check only verifies that every role has a non-empty action set — it says nothing about whether the actions are enforced.AC-3covers enforcement, by checking that every action in scope is actually owned by a role the evaluator checks against. A role model can passAC-2(accounts are well-defined) while a separate, unrelated code path bypasses the evaluator entirely and failsAC-3silently — the coverage report above cannot see a bypass that exists outside the role model it was given.- A
COVEREDverdict is only as good as the fixtures behind it.build_coverage_reportevaluatesROLE_MODEL,EMITTED_AUDIT_EVENTS, andAUDIT_RECORD_FIELDSas they are declared in this file — if the audit pipeline actually emitsaccess_grantedbut a downstream consumer drops it before persistence,AU-2still reportsCOVEREDhere, because this check only inspects the declared event vocabulary, not the live pipeline. Wire this report to the same audit sink used in production, not to a hand-maintained constant, before trusting it for a real review. - Field-level checks age faster than role checks.
enforce_least_privilegehardcodesread_financialsas the sensitive action guardingrent_amount_usd. The day a new action likeread_lease_escalationsexposes another sensitive field without updating this function,AC-6reportsCOVEREDwhile a real least-privilege violation exists uncaught — treat the sensitive-action list as part of the canonical field vocabulary, reviewed every time a new field or action is added, not a one-time constant.
FAQ
Why report per-control coverage instead of one overall pass/fail?
An overall pass/fail collapses five independent claims into one bit, so a single AU-2 gap either fails the whole system — hiding that AC-6 least privilege is fully sound — or, worse, gets rounded up to an overall pass because four of five controls are fine. Reporting each control’s verdict separately lets a reviewer, or a CI gate, act on exactly the gap that exists: block a release over AC-6, but let a missing role_changed audit event under AU-2 become a tracked follow-up instead of a deploy blocker.
How does this crosswalk relate to the RBAC evaluator itself?
Configuring RBAC for telecom infrastructure data builds the evaluator that grants or denies a single access request and seals that decision with its own SHA-256 hash. This page sits one layer above it: it does not evaluate individual requests, it evaluates the role model itself against named regulatory controls, and seals the resulting coverage report separately. The RBAC evaluator answers “was this request allowed”; this page answers “does the role model that governs all such requests actually satisfy AC-6.”
What happens when a new control, like AU-9, needs to be added to the crosswalk?
Add its identifier and description to CONTROLS, then add a corresponding branch to build_coverage_report that expresses what “covered” means for that control in terms of the existing fixtures — ROLE_MODEL, EMITTED_AUDIT_EVENTS, AUDIT_RECORD_FIELDS, or a new fixture the control requires. Nothing about the four existing branches needs to change, and the sealed digest for a fixed set of prior verdicts stays reproducible, since seal_report only ever hashes whatever pairs are currently in the report.
Related
- Parent topic: Regulatory Source Crosswalk
- Sibling guide: Validating FCC ASR numbers in Python
- Implementation this page evaluates: Configuring RBAC for telecom infrastructure data
- Reference hub: Telecom Compliance Data Reference