Módulo 5: Secrets Management
7. Least Privilege e Integración con Python
Descripción
Has construido las piezas de secrets management: almacenamiento seguro (03, 05), rotación automática (04), lifecycle management (06), y audit trails (06). Ahora necesitas integrar todo en tu aplicación Python de forma que cada servicio solo acceda a los secrets que necesita (least privilege), la integración con FastAPI sea limpia (dependency injection), y el sistema sea resiliente cuando el secrets service falla (fallback y caching).
El principio de least privilege es simple de entender pero difícil de implementar consistentemente: cada componente de tu sistema debe tener acceso solo a los secrets que necesita para funcionar — y nada más. Tu API service necesita la API key de OpenAI, pero no necesita la password de la base de datos del analytics. Tu migration script necesita acceso a la base de datos, pero no necesita la API key de Anthropic. Violar least privilege significa que comprometer un componente expone secrets que ese componente no debería conocer.
En esta cápsula vas a implementar scoped access, integrar secrets management con FastAPI usando dependency injection, construir patrones de fallback robustos, y configurar secrets por environment (dev/staging/prod). Todo el código de esta cápsula se integra directamente en el proyecto de la cápsula 08.
Least Privilege: el principio
Sin Least Privilege Con Least Privilege
────────────────── ──────────────────────
┌─────────────────┐ ┌─────────────────┐
│ API Service │ │ API Service │
│ │ │ │
│ OPENAI_KEY ✅ │ │ OPENAI_KEY ✅ │
│ ANTHRO_KEY ✅ │ │ ANTHRO_KEY ❌ │
│ DB_PASSWORD ✅ │ ← Todos ven │ DB_PASSWORD ❌ │ ← Solo lo que
│ REDIS_URL ✅ │ todo │ REDIS_URL ❌ │ necesita
│ STRIPE_KEY ✅ │ │ STRIPE_KEY ❌ │
│ ADMIN_PASS ✅ │ │ ADMIN_PASS ❌ │
└─────────────────┘ └─────────────────┘
Implementación de scoped access
import json
from dataclasses import dataclass, field
from typing import Optional, Set
from enum import Enum
class Permission(Enum):
READ = "read"
WRITE = "write"
ROTATE = "rotate"
DELETE = "delete"
@dataclass
class ServicePolicy:
service_name: str
allowed_secrets: Set[str]
permissions: Set[Permission] = field(default_factory=lambda: {Permission.READ})
description: str = ""
def can_access(self, secret_name: str, permission: Permission = Permission.READ) -> bool:
return secret_name in self.allowed_secrets and permission in self.permissions
class PolicyEnforcer:
"""Enforce least privilege policies para acceso a secrets."""
def __init__(self):
self._policies: dict[str, ServicePolicy] = {}
def register_policy(self, policy: ServicePolicy):
self._policies[policy.service_name] = policy
def check_access(
self,
service_name: str,
secret_name: str,
permission: Permission = Permission.READ,
) -> tuple[bool, str]:
policy = self._policies.get(service_name)
if policy is None:
return False, f"No policy defined for service '{service_name}'"
if not policy.can_access(secret_name, permission):
if secret_name not in policy.allowed_secrets:
return False, f"Service '{service_name}' is not allowed to access '{secret_name}'"
return False, f"Service '{service_name}' lacks '{permission.value}' permission for '{secret_name}'"
return True, "Access granted"
def get_service_secrets(self, service_name: str) -> list[str]:
policy = self._policies.get(service_name)
return sorted(policy.allowed_secrets) if policy else []
def audit_policies(self) -> dict:
report = {}
for name, policy in self._policies.items():
report[name] = {
"allowed_secrets": sorted(policy.allowed_secrets),
"permissions": [p.value for p in policy.permissions],
"secret_count": len(policy.allowed_secrets),
}
return report
enforcer = PolicyEnforcer()
enforcer.register_policy(ServicePolicy(
service_name="api-service",
allowed_secrets={"openai-api-key", "app-config"},
permissions={Permission.READ},
description="Main API service — needs LLM key and app config",
))
enforcer.register_policy(ServicePolicy(
service_name="worker-service",
allowed_secrets={"database-url", "redis-url"},
permissions={Permission.READ},
description="Background worker — needs DB and cache access",
))
enforcer.register_policy(ServicePolicy(
service_name="migration-service",
allowed_secrets={"database-url"},
permissions={Permission.READ},
description="Database migrations — needs DB access only",
))
enforcer.register_policy(ServicePolicy(
service_name="rotation-scheduler",
allowed_secrets={"openai-api-key", "anthropic-api-key", "database-url"},
permissions={Permission.READ, Permission.WRITE, Permission.ROTATE},
description="Rotation service — needs read/write/rotate for managed secrets",
))
test_cases = [
("api-service", "openai-api-key", Permission.READ),
("api-service", "database-url", Permission.READ),
("worker-service", "database-url", Permission.READ),
("worker-service", "openai-api-key", Permission.READ),
("migration-service", "database-url", Permission.READ),
("migration-service", "redis-url", Permission.READ),
("rotation-scheduler", "openai-api-key", Permission.ROTATE),
("unknown-service", "openai-api-key", Permission.READ),
]
print("Access Control Tests:")
for service, secret, perm in test_cases:
allowed, reason = enforcer.check_access(service, secret, perm)
status = "✅ ALLOW" if allowed else "❌ DENY"
print(f" {status} | {service} → {secret} ({perm.value})")
if not allowed:
print(f" Reason: {reason}")
print(f"\nPolicy Audit:")
print(json.dumps(enforcer.audit_policies(), indent=2))
# Output esperado:
# Access Control Tests:
# ✅ ALLOW | api-service → openai-api-key (read)
# ❌ DENY | api-service → database-url (read)
# Reason: Service 'api-service' is not allowed to access 'database-url'
# ✅ ALLOW | worker-service → database-url (read)
# ❌ DENY | worker-service → openai-api-key (read)
# ...
Per-Service Keys: un patrón clave
En lugar de una API key de OpenAI compartida por todos los servicios, crea keys separadas por servicio:
per_service_keys = {
"concept": "Cada servicio tiene su propia API key para el mismo proveedor",
"benefits": [
"Si un servicio se compromete, solo se revoca su key",
"Audit trail muestra qué servicio hizo cada llamada",
"Rate limits y costos son atribuibles por servicio",
"Rotación de una key no afecta a otros servicios",
],
"implementation": {
"api-service": {
"openai_key": "sk-proj-api-service-abc123",
"scope": "chat completions, embeddings",
"rate_limit": "500 RPM",
},
"batch-processor": {
"openai_key": "sk-proj-batch-proc-def456",
"scope": "embeddings only",
"rate_limit": "1000 RPM",
},
"evaluation-service": {
"openai_key": "sk-proj-eval-svc-ghi789",
"scope": "chat completions (eval model)",
"rate_limit": "100 RPM",
},
},
}
print("Per-Service Keys:")
for service, config in per_service_keys["implementation"].items():
print(f"\n {service}:")
print(f" Key: {config['openai_key'][:20]}...")
print(f" Scope: {config['scope']}")
print(f" Rate limit: {config['rate_limit']}")
print("\nBenefits:")
for benefit in per_service_keys["benefits"]:
print(f" ✅ {benefit}")
Integración con FastAPI: Dependency Injection
La forma más limpia de integrar secrets management con FastAPI es usando dependency injection. Los secrets se inyectan como dependencias en los endpoints que los necesitan:
import os
import json
import time
import logging
from typing import Optional, Annotated
from dataclasses import dataclass
from functools import lru_cache
from fastapi import FastAPI, Depends, HTTPException, Request
from pydantic import BaseModel, Field
logger = logging.getLogger("secrets_di")
@dataclass
class AppSecrets:
"""Container de secrets para la aplicación."""
openai_api_key: str
provider: str
cached: bool = False
class SecretsService:
"""Servicio de secrets con cache y fallback."""
def __init__(
self,
provider=None,
cache_ttl: int = 300,
service_name: str = "api-service",
):
self._provider = provider
self._cache_ttl = cache_ttl
self._service_name = service_name
self._cache: dict[str, tuple[str, float]] = {}
def get_secret(self, key: str) -> Optional[str]:
cached = self._get_cached(key)
if cached is not None:
return cached
if self._provider:
try:
result = self._provider.get(key)
if result.found:
self._set_cache(key, result.value)
return result.value
except Exception as e:
logger.warning(f"Provider failed for '{key}': {e}")
env_value = os.environ.get(key)
if env_value:
logger.info(f"Fallback to env for '{key}'")
return env_value
return None
def _get_cached(self, key: str) -> Optional[str]:
if key in self._cache:
value, cached_at = self._cache[key]
if time.time() - cached_at < self._cache_ttl:
return value
del self._cache[key]
return None
def _set_cache(self, key: str, value: str):
self._cache[key] = (value, time.time())
def clear_cache(self):
self._cache.clear()
secrets_service: Optional[SecretsService] = None
def get_secrets_service() -> SecretsService:
global secrets_service
if secrets_service is None:
secrets_service = SecretsService(service_name="api-service")
return secrets_service
def get_openai_key(
svc: Annotated[SecretsService, Depends(get_secrets_service)],
) -> str:
key = svc.get_secret("OPENAI_API_KEY")
if not key:
raise HTTPException(status_code=503, detail="Service temporarily unavailable")
return key
def get_app_secrets(
svc: Annotated[SecretsService, Depends(get_secrets_service)],
) -> AppSecrets:
openai_key = svc.get_secret("OPENAI_API_KEY")
if not openai_key:
raise HTTPException(status_code=503, detail="Service temporarily unavailable")
return AppSecrets(
openai_api_key=openai_key,
provider="env_fallback",
)
app = FastAPI(title="AI API with Secrets Management")
class ChatRequest(BaseModel):
message: str = Field(min_length=1, max_length=5000)
class ChatResponse(BaseModel):
answer: str
model: str = "gpt-4o-mini"
@app.post("/chat", response_model=ChatResponse)
async def chat(
request: ChatRequest,
openai_key: Annotated[str, Depends(get_openai_key)],
):
# openai_key se inyecta automáticamente desde el secrets service
# No necesitas os.getenv() ni acceder al .env directamente
return ChatResponse(
answer=f"Response to: {request.message[:50]}...",
model="gpt-4o-mini",
)
@app.post("/chat-v2", response_model=ChatResponse)
async def chat_v2(
request: ChatRequest,
secrets: Annotated[AppSecrets, Depends(get_app_secrets)],
):
# secrets contiene todos los secrets que este endpoint necesita
# Cada endpoint declara explícitamente qué secrets usa
return ChatResponse(
answer=f"Response using {secrets.provider}",
model="gpt-4o-mini",
)
@app.get("/health")
async def health(
svc: Annotated[SecretsService, Depends(get_secrets_service)],
):
openai_key = svc.get_secret("OPENAI_API_KEY")
return {
"status": "ok" if openai_key else "degraded",
"secrets_available": openai_key is not None,
}
Middleware de audit para secrets access
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecretsAuditMiddleware(BaseHTTPMiddleware):
"""Middleware que registra acceso a secrets por request."""
def __init__(self, app, audit_logger=None):
super().__init__(app)
self.audit_logger = audit_logger
async def dispatch(self, request: Request, call_next):
request.state.secrets_accessed = []
request.state.request_id = f"req-{time.time_ns() % 1000000:06d}"
response = await call_next(request)
if self.audit_logger and request.state.secrets_accessed:
for secret_name in request.state.secrets_accessed:
self.audit_logger.log(
action="read",
secret_name=secret_name,
actor=f"endpoint:{request.url.path}",
source_ip=request.client.host if request.client else "unknown",
details={"request_id": request.state.request_id},
)
return response
Fallback y Resilience
¿Qué pasa cuando el secrets service no está disponible? Tu app necesita un plan B:
import time
import logging
from typing import Optional
from dataclasses import dataclass, field
from enum import Enum
logger = logging.getLogger("resilience")
class FallbackLevel(Enum):
PRIMARY = "primary"
CACHE = "cache"
ENCRYPTED_LOCAL = "encrypted_local"
ENV_VARS = "env_vars"
UNAVAILABLE = "unavailable"
@dataclass
class FallbackResult:
value: Optional[str]
level: FallbackLevel
message: str
class ResilientSecretsClient:
"""Cliente de secrets con múltiples niveles de fallback."""
def __init__(
self,
primary_provider=None,
encrypted_local_path: Optional[str] = None,
cache_ttl_seconds: int = 600,
):
self._primary = primary_provider
self._local_path = encrypted_local_path
self._cache_ttl = cache_ttl_seconds
self._cache: dict[str, tuple[str, float]] = {}
self._circuit_breaker_open = False
self._circuit_breaker_until = 0.0
self._failure_count = 0
self._failure_threshold = 3
self._circuit_timeout = 60
def get(self, key: str) -> FallbackResult:
if self._primary and not self._is_circuit_open():
try:
result = self._primary.get(key)
if result.found:
self._cache[key] = (result.value, time.time())
self._reset_circuit()
return FallbackResult(result.value, FallbackLevel.PRIMARY, "From primary provider")
except Exception as e:
logger.warning(f"Primary provider failed: {e}")
self._record_failure()
if key in self._cache:
value, cached_at = self._cache[key]
if time.time() - cached_at < self._cache_ttl:
return FallbackResult(value, FallbackLevel.CACHE, "From cache (primary unavailable)")
import os
env_value = os.environ.get(key)
if env_value:
return FallbackResult(env_value, FallbackLevel.ENV_VARS, "Fallback to environment variable")
return FallbackResult(None, FallbackLevel.UNAVAILABLE, f"Secret '{key}' unavailable from all sources")
def _is_circuit_open(self) -> bool:
if self._circuit_breaker_open:
if time.time() > self._circuit_breaker_until:
self._circuit_breaker_open = False
self._failure_count = 0
logger.info("Circuit breaker: half-open, testing primary")
return False
return True
return False
def _record_failure(self):
self._failure_count += 1
if self._failure_count >= self._failure_threshold:
self._circuit_breaker_open = True
self._circuit_breaker_until = time.time() + self._circuit_timeout
logger.warning(
f"Circuit breaker OPEN — primary disabled for {self._circuit_timeout}s"
)
def _reset_circuit(self):
self._failure_count = 0
self._circuit_breaker_open = False
def health_check(self) -> dict:
return {
"primary_available": self._primary is not None and not self._is_circuit_open(),
"circuit_breaker_open": self._circuit_breaker_open,
"failure_count": self._failure_count,
"cache_size": len(self._cache),
}
import os
os.environ["OPENAI_API_KEY"] = "sk-proj-fallback-from-env"
client = ResilientSecretsClient(primary_provider=None, cache_ttl_seconds=300)
result = client.get("OPENAI_API_KEY")
print(f"Value: {result.value[:20]}...")
print(f"Level: {result.level.value}")
print(f"Message: {result.message}")
print(f"Health: {json.dumps(client.health_check())}")
# Output esperado:
# Value: sk-proj-fallback-f...
# Level: env_vars
# Message: Fallback to environment variable
# Health: {"primary_available": false, "circuit_breaker_open": false, ...}
Environment-Specific Configuration
Diferentes environments necesitan diferentes sources de secrets y diferentes niveles de seguridad:
import os
from dataclasses import dataclass
from typing import Optional
from enum import Enum
class Environment(Enum):
DEVELOPMENT = "development"
STAGING = "staging"
PRODUCTION = "production"
@dataclass
class EnvironmentConfig:
environment: Environment
secrets_provider: str
cache_ttl: int
rotation_enabled: bool
audit_enabled: bool
fallback_to_env: bool
description: str
ENV_CONFIGS = {
Environment.DEVELOPMENT: EnvironmentConfig(
environment=Environment.DEVELOPMENT,
secrets_provider="env",
cache_ttl=0,
rotation_enabled=False,
audit_enabled=False,
fallback_to_env=True,
description="Dev: .env file, no rotation, no audit",
),
Environment.STAGING: EnvironmentConfig(
environment=Environment.STAGING,
secrets_provider="aws",
cache_ttl=300,
rotation_enabled=True,
audit_enabled=True,
fallback_to_env=True,
description="Staging: Cloud KMS with env fallback",
),
Environment.PRODUCTION: EnvironmentConfig(
environment=Environment.PRODUCTION,
secrets_provider="aws",
cache_ttl=600,
rotation_enabled=True,
audit_enabled=True,
fallback_to_env=False,
description="Prod: Cloud KMS only, no env fallback",
),
}
def get_environment() -> Environment:
env_str = os.environ.get("APP_ENV", "development").lower()
try:
return Environment(env_str)
except ValueError:
return Environment.DEVELOPMENT
def create_configured_client(env: Optional[Environment] = None) -> ResilientSecretsClient:
"""Factory que crea el secrets client apropiado para el environment."""
if env is None:
env = get_environment()
config = ENV_CONFIGS[env]
primary = None
if config.secrets_provider == "aws":
try:
primary = None # AWSProvider(region="us-east-1") en producción real
except Exception:
logger.warning("AWS provider unavailable, will use fallback")
return ResilientSecretsClient(
primary_provider=primary,
cache_ttl_seconds=config.cache_ttl,
)
for env, config in ENV_CONFIGS.items():
print(f"\n{env.value}:")
print(f" Provider: {config.secrets_provider}")
print(f" Cache TTL: {config.cache_ttl}s")
print(f" Rotation: {'✅' if config.rotation_enabled else '❌'}")
print(f" Audit: {'✅' if config.audit_enabled else '❌'}")
print(f" Env fallback: {'✅' if config.fallback_to_env else '❌'}")
Caching secrets de forma segura
Cachear secrets reduce latencia y dependencia del provider, pero introduce riesgos si no se hace correctamente:
import time
import threading
from typing import Optional
from dataclasses import dataclass
@dataclass
class CachedSecret:
key: str
value: str
cached_at: float
ttl: int
access_count: int = 0
@property
def is_expired(self) -> bool:
return time.time() - self.cached_at > self.ttl
@property
def age_seconds(self) -> float:
return time.time() - self.cached_at
class SecureSecretCache:
"""Cache de secrets con TTL, limits, y cleanup automático."""
def __init__(self, default_ttl: int = 300, max_entries: int = 100):
self._cache: dict[str, CachedSecret] = {}
self._default_ttl = default_ttl
self._max_entries = max_entries
self._lock = threading.Lock()
self._hits = 0
self._misses = 0
def get(self, key: str) -> Optional[str]:
with self._lock:
entry = self._cache.get(key)
if entry is None:
self._misses += 1
return None
if entry.is_expired:
del self._cache[key]
self._misses += 1
return None
entry.access_count += 1
self._hits += 1
return entry.value
def set(self, key: str, value: str, ttl: Optional[int] = None):
with self._lock:
if len(self._cache) >= self._max_entries:
self._evict_oldest()
self._cache[key] = CachedSecret(
key=key,
value=value,
cached_at=time.time(),
ttl=ttl or self._default_ttl,
)
def invalidate(self, key: str):
with self._lock:
self._cache.pop(key, None)
def clear(self):
with self._lock:
self._cache.clear()
self._hits = 0
self._misses = 0
def _evict_oldest(self):
if not self._cache:
return
oldest_key = min(self._cache, key=lambda k: self._cache[k].cached_at)
del self._cache[oldest_key]
def stats(self) -> dict:
total = self._hits + self._misses
return {
"entries": len(self._cache),
"max_entries": self._max_entries,
"hits": self._hits,
"misses": self._misses,
"hit_rate": f"{(self._hits/total*100):.1f}%" if total > 0 else "0%",
}
cache = SecureSecretCache(default_ttl=10, max_entries=5)
cache.set("openai-key", "sk-proj-abc123")
cache.set("anthropic-key", "sk-ant-def456")
print(f"Get openai-key: {cache.get('openai-key')[:15]}...")
print(f"Get missing: {cache.get('nonexistent')}")
print(f"Stats: {cache.stats()}")
# Output esperado:
# Get openai-key: sk-proj-abc123...
# Get missing: None
# Stats: {'entries': 2, 'max_entries': 5, 'hits': 1, 'misses': 1, 'hit_rate': '50.0%'}
IAM Policies para LLM Access
Configura IAM policies que restrinjan el acceso a secrets por servicio:
iam_policies = {
"aws_iam_for_api_service": {
"description": "IAM policy para el API service — solo lectura de LLM keys",
"policy_json": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:prod/llm/openai-*",
"arn:aws:secretsmanager:*:*:secret:prod/app/config-*",
],
},
{
"Effect": "Deny",
"Action": ["secretsmanager:*"],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:prod/database/*",
"arn:aws:secretsmanager:*:*:secret:prod/admin/*",
],
},
],
},
},
"aws_iam_for_rotation_service": {
"description": "IAM policy para rotation — read + write de secrets gestionados",
"policy_json": {
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"secretsmanager:GetSecretValue",
"secretsmanager:PutSecretValue",
"secretsmanager:UpdateSecret",
"secretsmanager:DescribeSecret",
],
"Resource": [
"arn:aws:secretsmanager:*:*:secret:prod/llm/*",
],
},
],
},
},
}
for policy_name, info in iam_policies.items():
print(f"\n=== {policy_name} ===")
print(f"Description: {info['description']}")
print(f"Policy:\n{json.dumps(info['policy_json'], indent=2)}")
Patrón completo: FastAPI + Secrets + Least Privilege
Aquí está el patrón completo que combina todo lo anterior:
import os
import json
import time
import logging
from typing import Annotated, Optional
from functools import lru_cache
from fastapi import FastAPI, Depends, HTTPException, Request
from pydantic import BaseModel, Field
logger = logging.getLogger("app")
class AppConfig(BaseModel):
environment: str = "development"
secrets_cache_ttl: int = 300
service_name: str = "ai-api-service"
@lru_cache()
def get_config() -> AppConfig:
return AppConfig(environment=os.getenv("APP_ENV", "development"))
class SecureAPIApp:
"""Aplicación FastAPI con secrets management integrado."""
def __init__(self, config: AppConfig):
self.config = config
self.app = FastAPI(title="Secure AI API")
self._secrets_svc = SecretsService(
service_name=config.service_name,
cache_ttl=config.secrets_cache_ttl,
)
self._setup_routes()
def _setup_routes(self):
app = self.app
secrets_svc = self._secrets_svc
def get_secrets() -> SecretsService:
return secrets_svc
@app.post("/api/chat")
async def chat(
message: str,
svc: Annotated[SecretsService, Depends(get_secrets)],
):
api_key = svc.get_secret("OPENAI_API_KEY")
if not api_key:
raise HTTPException(503, "AI service temporarily unavailable")
return {"response": f"Processed: {message[:30]}...", "service": "ai-api"}
@app.get("/api/health")
async def health(svc: Annotated[SecretsService, Depends(get_secrets)]):
checks = {
"openai_key": svc.get_secret("OPENAI_API_KEY") is not None,
"service": self.config.service_name,
"environment": self.config.environment,
}
status = "healthy" if all(v for k, v in checks.items() if k != "service" and k != "environment") else "degraded"
return {"status": status, **checks}
@app.post("/internal/cache/clear")
async def clear_cache(svc: Annotated[SecretsService, Depends(get_secrets)]):
svc.clear_cache()
return {"status": "cache cleared"}
config = get_config()
secure_app = SecureAPIApp(config)
# uvicorn: app = secure_app.app
Troubleshooting
"La dependency injection hace que cada request lea el secret"
Usa caching en el SecretsService. Con un TTL de 300 segundos, solo una de cada N requests lee del provider real. El cache absorbe el tráfico repetido.
"El circuit breaker se abre y nunca se cierra"
Verifica el _circuit_timeout. Cuando el circuit breaker está open, espera hasta que pase el timeout para intentar de nuevo (half-open state). Si el provider sigue fallando, se abre de nuevo.
"No sé qué permissions dar a cada servicio"
Empieza con READ only para todos los servicios. Solo agrega WRITE y ROTATE a los servicios que explícitamente lo necesitan (rotation scheduler, admin CLI). Menos es más.
"El fallback a env vars en producción es inseguro"
Correcto. En producción, fallback_to_env debe ser False. El fallback a env vars es solo para desarrollo y staging. Si el provider cloud no está disponible en producción, la app debe responder con 503 y alertar al equipo.
"El cache de secrets ocupa mucha memoria"
SecureSecretCache tiene max_entries configurable. Con 100 entries y secrets de ~100 bytes cada uno, el consumo es ~10KB. No es un problema en la práctica.
Ejercicios
Ejercicio 1: Crea policies para 4 servicios diferentes
Define policies de least privilege para: API gateway, ML inference service, data pipeline, y admin dashboard:
Ver solución
enforcer = PolicyEnforcer()
services = [
ServicePolicy("api-gateway", {"openai-api-key", "rate-limit-config"}, {Permission.READ},
"Gateway: LLM key + config"),
ServicePolicy("ml-inference", {"openai-api-key", "model-config"}, {Permission.READ},
"ML: LLM key + model config"),
ServicePolicy("data-pipeline", {"database-url", "s3-credentials"}, {Permission.READ},
"Pipeline: DB + storage"),
ServicePolicy("admin-dashboard", {"database-url", "admin-token"}, {Permission.READ, Permission.WRITE},
"Admin: DB + admin token with write"),
]
for policy in services:
enforcer.register_policy(policy)
print(json.dumps(enforcer.audit_policies(), indent=2))
Ejercicio 2: Implementa un health endpoint con secrets status
Crea un endpoint que muestre el estado de cada secret que el servicio necesita:
Ver solución
def detailed_health(secrets_svc: SecretsService, required_secrets: list[str]) -> dict:
checks = {}
all_ok = True
for key in required_secrets:
value = secrets_svc.get_secret(key)
available = value is not None
checks[key] = {
"available": available,
"key_preview": f"{value[:4]}..." if value else None,
}
if not available:
all_ok = False
return {
"status": "healthy" if all_ok else "degraded",
"checks": checks,
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
}
svc = SecretsService()
os.environ["OPENAI_API_KEY"] = "sk-test"
result = detailed_health(svc, ["OPENAI_API_KEY", "MISSING_KEY"])
print(json.dumps(result, indent=2))
Ejercicio 3: Implementa rate limiting para accesos a secrets
Previene que un servicio haga demasiados accesos a secrets en poco tiempo:
Ver solución
class RateLimitedSecretsService(SecretsService):
def __init__(self, max_reads_per_minute: int = 60, **kwargs):
super().__init__(**kwargs)
self._max_rpm = max_reads_per_minute
self._access_times: list[float] = []
def get_secret(self, key: str) -> Optional[str]:
now = time.time()
self._access_times = [t for t in self._access_times if now - t < 60]
if len(self._access_times) >= self._max_rpm:
logger.warning(f"Rate limit exceeded: {len(self._access_times)} reads/min")
raise Exception("Secret access rate limit exceeded")
self._access_times.append(now)
return super().get_secret(key)
Ejercicio 4: Crea un decorator @inject_secret para FastAPI
Crea un decorator reutilizable que inyecte un secret específico en un endpoint:
Ver solución
from functools import wraps
def inject_secret(secret_key: str, param_name: str = "api_key"):
def decorator(func):
@wraps(func)
async def wrapper(*args, **kwargs):
svc = get_secrets_service()
value = svc.get_secret(secret_key)
if not value:
raise HTTPException(503, "Required secret unavailable")
kwargs[param_name] = value
return await func(*args, **kwargs)
return wrapper
return decorator
# Uso:
# @app.post("/generate")
# @inject_secret("OPENAI_API_KEY", "api_key")
# async def generate(prompt: str, api_key: str = ""):
# ...
Resumen
- Least privilege significa que cada servicio solo accede a los secrets que necesita — implementado con
PolicyEnforceryServicePolicy - Per-service keys eliminan el problema de credenciales compartidas: cada servicio tiene su propia API key del mismo proveedor
- La integración con FastAPI usa dependency injection: los secrets se inyectan como dependencias, no se leen con
os.getenv() - Fallback y resilience con
ResilientSecretsClient: primary provider → cache → env vars, con circuit breaker para evitar cascading failures - Environment-specific config distingue entre dev (.env), staging (cloud + fallback), y prod (cloud only, no fallback)
- Caching seguro con
SecureSecretCache: TTL configurable, max entries, thread-safe, eviction automática - IAM policies (AWS, GCP, Azure) implementan least privilege a nivel de infraestructura — complementan las policies de aplicación
- El patrón completo combina: config por environment → secrets service con cache → dependency injection → audit middleware
Próxima cápsula: En la cápsula 08 vas a construir el proyecto Secrets Management Setup completo: un sistema integrado con secrets client abstraction, rotation scheduler, audit logger, FastAPI integration, y testing con mock vault — todo como un artefacto reutilizable y production-ready.
Recursos
- OWASP Least Privilege Principle — Guía de OWASP para implementar least privilege en aplicaciones
- FastAPI Dependencies — Documentación oficial de dependency injection en FastAPI
- AWS IAM Best Practices — Mejores prácticas de IAM para least privilege en AWS
- Circuit Breaker Pattern — Patrón de resilience para servicios externos
- Python functools.lru_cache — Caching built-in de Python para singletons
- GCP IAM Conditions — Condiciones avanzadas de IAM para access control granular
- 12-Factor App — Backing Services — Principio de servicios de respaldo como recursos adjuntos
- OpenAI API Key Management — Prácticas de seguridad oficiales de OpenAI para API keys
Creado: Marzo 2026 Versión: 1.0