Free lesson · GenAI Platform Engineering
Deploy RBAC with policy-as-code validation
Define RBAC policies as declarative YAML files, validate them at deploy time, and sync them to the database via ArgoCD. Build policy drift detection.
Course: AI Developer Platform Engineering · Chapter 7 · RBAC & Access Control
Free to read — no subscription required.
Introduction
Engineers often manage RBAC configurations through admin dashboards or ad-hoc scripts, which creates silent privilege escalation, unauditable changes, and configuration drift between environments. Without a version-controlled source of truth, no one can reconstruct why a permission exists or which environment reflects the intended policy. Policy-as-code solves this by treating RBAC definitions as declarative files that pass through automated validation before any change reaches a running system. By the end of this lesson, you'll be able to author declarative YAML policy files, validate them with schema and semantic checks, sync them through ArgoCD PostSync hooks, and build a drift detector that continuously compares runtime state against your repository.
Key Terminology
- Policy-as-Code: The practice of defining security policies, including RBAC configurations, as version-controlled declarative files that are validated and deployed through automated pipelines rather than manual administrative actions.
- Drift Detection: Continuous comparison between the declared state in a source-of-truth repository and the actual runtime state in a database or service, alerting on any divergence that indicates unauthorized or accidental changes.
- Idempotent Sync: A reconciliation operation that produces the same database state regardless of how many times it executes, typically implemented through upsert patterns with checksum-based change detection.
- PostSync Hook: An ArgoCD lifecycle hook that executes a Kubernetes Job after successful manifest synchronization, used here to trigger policy database reconciliation following Git-to-cluster sync.
- Privilege Escalation Path: A chain of role inheritance or permission assignment that allows a lower-privileged identity to gain permissions beyond its intended scope, detected during semantic validation of policy files.
Concepts
Why Policy-as-Code Matters for Platform RBAC
Traditional RBAC management suffers from three failure modes: configuration opacity (changes hidden in migration logs), environment divergence (staging roles missing in production), and validation gaps (circular inheritance or contradictory permissions going undetected). Policy-as-code solves these by enforcing a single declarative representation validated through automated checks before any change reaches a running system, integrating naturally with GitOps workflows where ArgoCD reconciles cluster state against a Git repository.
Building Drift Detection
Policy drift occurs when runtime RBAC state diverges from the declared state in Git — caused by manual database edits, direct API calls bypassing GitOps, or failed sync jobs. Drift detection runs as a periodic CronJob (typically every 5 minutes) comparing database checksums against policy file checksums and emitting structured alerts on mismatches.
The detector loads the current policy YAML, computes expected checksums, queries actual checksums from the database, and reports discrepancies. It can optionally trigger automatic re-sync via PolicySyncService, implementing a self-healing loop.
- Policy Staleness: Compare synced_at timestamps against the last Git commit timestamp to detect sync jobs that stopped running.
- Permission Inflation: Flag roles whose database permissions are a superset of their declared permissions, indicating unauthorized privilege escalation.
- Orphan Detection: Identify database roles that exist in no policy file across any environment, suggesting manual creation that was never codified.
Integrating with CI/CD Pipelines
The components integrate into a standard GitOps workflow through three stages: in the pull request stage, CI runs validate_policy_file and blocks merge on any ERROR; in the merge stage, ArgoCD detects updated files and triggers sync; in the post-sync stage, a resource hook launches the sync Job. For platforms with more than 50 roles, running the sync as a separate ArgoCD Application (rather than a PostSync hook) provides better observability and retry semantics.
Code Walkthrough
Now that you understand drift detection, idempotent sync, and PostSync hooks, let's implement the two core components: a PolicyValidator that blocks unsafe policy files before merge, and a checksum-based drift detector that surfaces out-of-band changes at runtime.
The PolicyValidator enforces structural correctness (valid scopes and permissions) and semantic correctness (circular inheritance detection). It returns a list of ValidationError objects your CI pipeline inspects to block or allow a merge.
Code snippetpython
1from dataclasses import dataclass 2from enum import Enum 3from typing import Optional 4import yaml 5 6class Severity(Enum): 7 ERROR = "error" 8 WARNING = "warning" 9 10@dataclass 11class ValidationError: 12 rule: str 13 message: str 14 severity: Severity 15 path: Optional[str] = None 16 17VALID_PERMISSIONS = frozenset({ 18 "read", "write", "delete", "admin", "manage_members", 19 "manage_roles", "manage_billing", "deploy", "audit_read", 20}) 21VALID_SCOPES = frozenset({"organization", "team", "project", "resource"}) 22 23class PolicyValidator: 24 def __init__(self, policy_data: dict): 25 self.policy = policy_data 26 self.errors: list[ValidationError] = [] 27 28 def validate_structure(self) -> None: 29 roles = self.policy.get("roles", {}) 30 if not roles: 31 self.errors.append(ValidationError( 32 rule="S001", message="Policy defines no roles", 33 severity=Severity.ERROR, 34 )) 35 return 36 for role_name, role_def in roles.items(): 37 scope = role_def.get("scope") 38 if scope not in VALID_SCOPES: 39 self.errors.append(ValidationError( 40 rule="S002", 41 message=f"Role '{role_name}' has invalid scope '{scope}'", 42 severity=Severity.ERROR, 43 path=f"roles.{role_name}.scope", 44 )) 45 for perm in role_def.get("permissions", []): 46 if perm not in VALID_PERMISSIONS: 47 self.errors.append(ValidationError( 48 rule="S003", 49 message=f"Unknown permission '{perm}' in role '{role_name}'", 50 severity=Severity.ERROR, 51 path=f"roles.{role_name}.permissions", 52 )) 53 54 def detect_circular_inheritance(self) -> None: 55 roles = self.policy.get("roles", {}) 56 57 def has_cycle(role: str, visited: set, stack: set) -> bool: 58 visited.add(role) 59 stack.add(role) 60 for parent in roles.get(role, {}).get("inherits", []): 61 if parent not in visited: 62 if has_cycle(parent, visited, stack): 63 return True 64 elif parent in stack: 65 return True 66 stack.discard(role) 67 return False 68 69 visited: set = set() 70 for role in roles: 71 if role not in visited and has_cycle(role, visited, set()): 72 self.errors.append(ValidationError( 73 rule="S004", 74 message=f"Circular inheritance detected involving role '{role}'", 75 severity=Severity.ERROR, 76 )) 77 78 def run(self) -> list[ValidationError]: 79 self.validate_structure() 80 self.detect_circular_inheritance() 81 return self.errors 82 83def validate_policy_file(path: str) -> list[ValidationError]: 84 with open(path) as f: 85 data = yaml.safe_load(f) 86 return PolicyValidator(data).run()
The drift detector runs as a CronJob, computing a SHA-256 checksum of each policy file and comparing it against the checksum stored in the database after the last sync. Mismatches emit a structured alert and can trigger a re-sync via PolicySyncService.
Code snippetpython
1import hashlib 2import json 3from datetime import datetime, timezone 4from pathlib import Path 5 6def compute_policy_checksum(policy_path: str) -> str: 7 content = Path(policy_path).read_bytes() 8 return hashlib.sha256(content).hexdigest() 9 10def detect_drift(policy_path: str, db_checksum: str) -> dict: 11 expected = compute_policy_checksum(policy_path) 12 return { 13 "policy_file": policy_path, 14 "expected_checksum": expected, 15 "actual_checksum": db_checksum, 16 "drift_detected": expected != db_checksum, 17 "checked_at": datetime.now(timezone.utc).isoformat(), 18 } 19 20# Simulate a drift event where the database checksum no longer matches the file 21result = detect_drift("policies/rbac.yaml", db_checksum="stale_checksum_abc123") 22if result["drift_detected"]: 23 print(json.dumps(result, indent=2))
Verify by calling validate_policy_file against a YAML that contains a role inheriting from itself and confirming the returned list includes a ValidationError with rule="S004", then calling detect_drift with a deliberately wrong db_checksum and confirming drift_detected is True in the printed JSON.
Do's and Don'ts
Do's
- ✓Do version every policy change through Git — Every RBAC modification must be a pull request with code review. This creates an immutable audit trail and enables rollback by reverting a commit, which is orders of magnitude faster than manually reconstructing previous permission states.
- ✓Do compute checksums deterministically — Use sorted-key serialization before hashing to ensure that logically identical policies always produce identical checksums. Non-deterministic serialization causes unnecessary database writes and obscures real changes in audit logs.
- ✓Do run drift detection on a tight schedule — A 5-minute detection interval limits the window during which unauthorized permission changes can persist undetected. Pair detection with automatic re-sync to achieve self-healing behavior that converges runtime state without human intervention.
Don'ts
- ✗Don't allow direct database writes for RBAC changes — Every manual INSERT or UPDATE against the roles table bypasses validation, escapes the audit trail, and will be overwritten by the next sync job. Restrict database write access to the sync service account exclusively.
- ✗Don't skip semantic validation in favor of schema-only checks — A YAML file can be structurally valid while containing circular inheritance, privilege escalation paths, or scope violations. Schema validation catches typos; semantic validation catches security vulnerabilities.
- ✗Don't store environment-specific overrides in the same policy file — Use separate policy files per environment (e.g., rbac-production.yaml, rbac-staging.yaml) with a shared base. Merging environment-specific roles into one file leads to accidental cross-environment permission leaks when a staging-only debug role appears in production.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Platform Engineering subscription.
From · cancel anytime · Already a subscriber? Sign in →
More free lessons in AI Developer Platform Engineering
- Ch 4Monitor gateway latency and token usage with Prometheus
- Ch 6Implement K8s namespace provisioning with quota enforcement
- Ch 6Deploy multi-tenant infrastructure with Helm overrides
- Ch 7Design RBAC model with roles, permissions, and scopes
- Ch 7Deploy RBAC with policy-as-code validationYou are here
- Ch 9Build cost tracking pipeline from gateway metrics
- Ch 9Deploy cost dashboards with Grafana