Módulo 7: The Incident And Data Governance
Quién puede ver qué: fundamentos de control de acceso
Descripción
Hasta esta lección, ninguna tabla de esta guía —orders_s04, dim_product, dim_store— tenía una columna que identificara a una persona real. Esta lección introduce customers, el directorio de contacto de la app de delivery de Kiosko: seis clientes fijos, cada uno con un customer_id, un customer_phone y un customer_email. Con esa tabla nueva llega una pregunta que ninguna herramienta de calidad de datos de los seis módulos anteriores necesitó responder: una vez que el dato ya está adentro, correcto o no, ¿quién puede verlo? Esta lección construye ACCESS_POLICY —un diccionario que dice, por rol de negocio, qué columnas puede ver cada quien— y apply_access_policy(), la función que lo aplica de verdad, sobre dos tablas distintas de Kiosko.
Conexión con el módulo. Las lecciones 2 a 4 cerraron la primera mitad de este módulo: qué hacer cuando un check falla. Esta lección abre la segunda mitad —gobierno de datos— con la primera de sus tres piezas: quién puede ver qué columna. Las lecciones 6 y 7 completan el gobierno con el enmascaramiento determinista de PII y un catálogo mínimo.
Una analogía: el pasaporte, y quién necesita ver cada página
Piensa en un pasaporte físico. No todas las personas que lo revisan necesitan ver exactamente la misma información. Un oficial de migración necesita ver la foto, el nombre completo y el número de pasaporte, para confirmar que la persona frente a él es quien dice ser. Un cajero de una tienda libre de impuestos, que solo necesita confirmar que compraste algo fuera de tu país de residencia, no necesita el número de pasaporte completo — le basta con el nombre y la nacionalidad. Y una persona que simplemente está sentada al lado tuyo en la sala de espera del aeropuerto no tiene ningún motivo legítimo para ver ni una sola página de tu pasaporte. El documento es exactamente el mismo en los tres casos — lo que cambia es quién lo está mirando, y con qué propósito legítimo.
ACCESS_POLICY es, con precisión, esa misma idea aplicada a las tablas de Kiosko: la tabla customers es siempre la misma, pero lo que un analista de datos, alguien de finanzas y alguien de soporte al cliente necesitan ver de ella —y tienen un motivo legítimo para ver— no es lo mismo. Esta lección construye esa diferencia, columna por columna, rol por rol.
El material: la tabla customers, nueva en esta guía
customers no extiende el esquema compartido de orders/fact_orders que ya conoces de las ocho guías anteriores del ecosistema — es una tabla completamente nueva, el directorio de contacto que la app de delivery de Kiosko siempre tuvo de forma implícita, y que esta guía es la primera en necesitar de forma explícita. Seis clientes, tres columnas, datos fijos:
# setup_customers.py
import duckdb
con = duckdb.connect("kiosko.duckdb")
con.execute("""
CREATE OR REPLACE TABLE customers (
customer_id VARCHAR, customer_phone VARCHAR, customer_email VARCHAR
)
""")
con.execute("""
INSERT INTO customers VALUES
('C001', '+57-300-555-0101', 'ana.torres@example.com'),
('C002', '+51-999-555-0102', 'luis.rojas@example.com'),
('C003', '+56-9-5550-0103', 'maria.fuentes@example.com'),
('C004', '+52-55-5550-0104', 'jorge.medina@example.com'),
('C005', '+57-300-555-0105', 'carla.suarez@example.com'),
('C006', '+51-999-555-0106', 'sofia.vargas@example.com')
""")
print(con.sql("SELECT * FROM customers"))
Fíjate en el dominio de los correos: example.com — el dominio reservado exactamente para este propósito por el estándar técnico de internet (RFC 2606), nunca resuelto a ningún servidor real. Ningún dato de esta tabla identifica a una persona real de ningún tipo; son datos de ejemplo, fijos, con la misma disciplina de determinismo que ya exigió cada tabla anterior de esta guía. customer_id no se conecta con ninguna columna de orders_s04 en esta guía —esta guía no extiende ese esquema, como ya aclaró el DISEÑO—; customers es, deliberadamente, una tabla independiente, el directorio completo de contactos de Kiosko, no una tabla de detalle de una orden específica.
Ejemplo trabajado: ACCESS_POLICY y apply_access_policy()
# access_control.py -- leccion 5 del modulo 7
import duckdb
import polars as pl
pl.Config.set_fmt_str_lengths(60)
ACCESS_POLICY: dict[str, list[str]] = {
"analyst": [
"order_id", "store_id", "product_id", "quantity", "unit_price", "order_ts",
"customer_id", "customer_email",
],
"finance": [
"order_id", "store_id", "product_id", "quantity", "unit_price", "order_ts",
"customer_id", "customer_email", "customer_phone",
],
"support": [
"order_id", "customer_id", "customer_email", "customer_phone",
],
}
def apply_access_policy(df: pl.DataFrame, role: str, policy: dict[str, list[str]] = ACCESS_POLICY) -> pl.DataFrame:
"""Filtra df a solo las columnas que ACCESS_POLICY permite ver a `role`.
Columnas de policy[role] que no existen en df se ignoran -- la misma politica
puede aplicarse a tablas distintas (orders_s04, customers) sin ningun cambio.
"""
allowed = [c for c in policy[role] if c in df.columns]
return df.select(allowed)
def main() -> None:
con = duckdb.connect("kiosko.duckdb")
orders_df = con.sql("SELECT * FROM orders_s04").pl()
customers_df = con.sql("SELECT * FROM customers").pl()
print("=== ACCESS_POLICY aplicada a orders_s04 (primeras 3 filas por rol) ===")
for role in ["analyst", "finance", "support"]:
view = apply_access_policy(orders_df, role)
print(f"\n--- role={role} | columnas: {view.columns} ---")
print(view.head(3))
print("\n\n=== ACCESS_POLICY aplicada a customers (tabla completa por rol) ===")
for role in ["analyst", "finance", "support"]:
view = apply_access_policy(customers_df, role)
print(f"\n--- role={role} | columnas: {view.columns} ---")
print(view)
if __name__ == "__main__":
main()
Qué esperar (verificado corriendo python3 access_control.py real, con kiosko.duckdb conteniendo orders_s04 y customers, polars==1.43.2):
=== ACCESS_POLICY aplicada a orders_s04 (primeras 3 filas por rol) ===
--- role=analyst | columnas: ['order_id', 'store_id', 'product_id', 'quantity', 'unit_price', 'order_ts'] ---
shape: (3, 6)
┌──────────┬──────────┬────────────┬──────────┬────────────┬─────────────────────┐
│ order_id ┆ store_id ┆ product_id ┆ quantity ┆ unit_price ┆ order_ts │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str ┆ i64 ┆ f64 ┆ datetime[μs] │
╞══════════╪══════════╪════════════╪══════════╪════════════╪═════════════════════╡
│ ORD-9501 ┆ S04 ┆ P001 ┆ 3 ┆ 0.55 ┆ 2026-08-14 08:05:00 │
│ ORD-9502 ┆ S04 ┆ P002 ┆ 2 ┆ 1.2 ┆ 2026-08-14 08:12:00 │
│ ORD-9503 ┆ S04 ┆ P003 ┆ 1 ┆ null ┆ 2026-08-14 08:19:00 │
└──────────┴──────────┴────────────┴──────────┴────────────┴─────────────────────┘
--- role=finance | columnas: ['order_id', 'store_id', 'product_id', 'quantity', 'unit_price', 'order_ts'] ---
shape: (3, 6)
┌──────────┬──────────┬────────────┬──────────┬────────────┬─────────────────────┐
│ order_id ┆ store_id ┆ product_id ┆ quantity ┆ unit_price ┆ order_ts │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str ┆ i64 ┆ f64 ┆ datetime[μs] │
╞══════════╪══════════╪════════════╪══════════╪════════════╪═════════════════════╡
│ ORD-9501 ┆ S04 ┆ P001 ┆ 3 ┆ 0.55 ┆ 2026-08-14 08:05:00 │
│ ORD-9502 ┆ S04 ┆ P002 ┆ 2 ┆ 1.2 ┆ 2026-08-14 08:12:00 │
│ ORD-9503 ┆ S04 ┆ P003 ┆ 1 ┆ null ┆ 2026-08-14 08:19:00 │
└──────────┴──────────┴────────────┴──────────┴────────────┴─────────────────────┘
--- role=support | columnas: ['order_id'] ---
shape: (3, 1)
┌──────────┐
│ order_id │
│ --- │
│ str │
╞══════════╡
│ ORD-9501 │
│ ORD-9502 │
│ ORD-9503 │
└──────────┘
=== ACCESS_POLICY aplicada a customers (tabla completa por rol) ===
--- role=analyst | columnas: ['customer_id', 'customer_email'] ---
shape: (6, 2)
┌─────────────┬───────────────────────────┐
│ customer_id ┆ customer_email │
│ --- ┆ --- │
│ str ┆ str │
╞═════════════╪═══════════════════════════╡
│ C001 ┆ ana.torres@example.com │
│ C002 ┆ luis.rojas@example.com │
│ C003 ┆ maria.fuentes@example.com │
│ C004 ┆ jorge.medina@example.com │
│ C005 ┆ carla.suarez@example.com │
│ C006 ┆ sofia.vargas@example.com │
└─────────────┴───────────────────────────┘
--- role=finance | columnas: ['customer_id', 'customer_email', 'customer_phone'] ---
shape: (6, 3)
┌─────────────┬───────────────────────────┬──────────────────┐
│ customer_id ┆ customer_email ┆ customer_phone │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str │
╞═════════════╪═══════════════════════════╪══════════════════╡
│ C001 ┆ ana.torres@example.com ┆ +57-300-555-0101 │
│ C002 ┆ luis.rojas@example.com ┆ +51-999-555-0102 │
│ C003 ┆ maria.fuentes@example.com ┆ +56-9-5550-0103 │
│ C004 ┆ jorge.medina@example.com ┆ +52-55-5550-0104 │
│ C005 ┆ carla.suarez@example.com ┆ +57-300-555-0105 │
│ C006 ┆ sofia.vargas@example.com ┆ +51-999-555-0106 │
└─────────────┴───────────────────────────┴──────────────────┘
--- role=support | columnas: ['customer_id', 'customer_email', 'customer_phone'] ---
shape: (6, 3)
┌─────────────┬───────────────────────────┬──────────────────┐
│ customer_id ┆ customer_email ┆ customer_phone │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str │
╞═════════════╪═══════════════════════════╪══════════════════╡
│ C001 ┆ ana.torres@example.com ┆ +57-300-555-0101 │
│ C002 ┆ luis.rojas@example.com ┆ +51-999-555-0102 │
│ C003 ┆ maria.fuentes@example.com ┆ +56-9-5550-0103 │
│ C004 ┆ jorge.medina@example.com ┆ +52-55-5550-0104 │
│ C005 ┆ carla.suarez@example.com ┆ +57-300-555-0105 │
│ C006 ┆ sofia.vargas@example.com ┆ +51-999-555-0106 │
└─────────────┴───────────────────────────┴──────────────────┘
Lee este resultado con cuidado, porque el mismo diccionario ACCESS_POLICY produce vistas muy distintas según qué tabla se le aplique — la misma política, dos comportamientos completamente distintos, sin cambiar una sola línea de apply_access_policy(). Sobre orders_s04: analyst y finance ven las seis columnas de negocio completas (nadie de esas dos columnas de ACCESS_POLICY es PII); support solo ve order_id — le basta con saber a qué orden se refiere un cliente que llama, sin necesitar el precio ni la cantidad exacta de esa venta. Sobre customers: analyst solo ve customer_id y customer_email —ni siquiera el teléfono, un analista de comportamiento de compra no tiene motivo para llamar a nadie—; finance y support ven las tres columnas completas, incluido customer_phone, porque ambos roles sí tienen un motivo legítimo de contactar directamente al cliente (facturación, en un caso; soporte de entrega, en el otro). Nota algo importante: en este ejemplo, todavía ningún valor está enmascarado — customer_email y customer_phone aparecen en texto plano para cualquier rol que la política le permita verlos. Eso es, precisamente, lo que falta, y lo que construye la lección 6.
Diagrama: una política, dos tablas, tres roles
flowchart TD
P["ACCESS_POLICY\n(un solo diccionario)"] --> O["apply_access_policy(orders_s04, role)"]
P --> C["apply_access_policy(customers, role)"]
O --> OA["analyst: 6 columnas\n(todas de negocio)"]
O --> OF["finance: 6 columnas\n(todas de negocio)"]
O --> OS["support: 1 columna\n(solo order_id)"]
C --> CA["analyst: 2 columnas\n(id + email)"]
C --> CF["finance: 3 columnas\n(id + email + telefono)"]
C --> CS["support: 3 columnas\n(id + email + telefono)"]
Profundización: por qué ACCESS_POLICY filtra columnas ausentes en silencio, en vez de fallar
Fíjate en una línea concreta de apply_access_policy(): [c for c in policy[role] if c in df.columns]. Esta línea hace que la política de finance, que menciona customer_phone, no falle cuando se aplica a orders_s04 —una tabla que nunca tuvo esa columna—; simplemente la ignora. Esta es una decisión de diseño deliberada, y vale la pena entender por qué.
La alternativa —hacer que apply_access_policy() lance un error si alguna columna de la política no existe en la tabla— parece, a primera vista, más "segura": forzaría a quien escribe ACCESS_POLICY a mantener una lista distinta y exacta por cada tabla. Pero esa alternativa rompe exactamente la ventaja que demostró el ejemplo trabajado de esta lección: una sola política, reusable sobre cualquier tabla que comparta algunas de sus columnas. ACCESS_POLICY["finance"] describe, en el fondo, un concepto de negocio —"todo lo que finanzas necesita, en cualquier tabla de Kiosko que lo tenga"—, no una lista atada a una tabla específica. Si Kiosko agregara una séptima tabla mañana, con una columna customer_email propia, la misma política ya sabría filtrarla correctamente, sin que nadie tuviera que escribir una entrada nueva. El costo de esta flexibilidad es que un error de tipeo en ACCESS_POLICY —una columna que debería existir, pero se escribió mal— falla en silencio, sin ningún aviso, en vez de lanzar una excepción inmediata. Es la misma clase de compromiso entre flexibilidad y detección temprana de errores que ya viste en el módulo 4, cuando pydantic decidía, con Literal[...], cuándo un valor debía fallar fuerte en vez de aceptarse silenciosamente — aquí, la guía elige el lado de la flexibilidad, con la advertencia explícita de este párrafo.
Errores comunes
Confundir ACCESS_POLICY con un control de acceso a nivel de fila. Qué pasa: alguien espera que apply_access_policy() también filtre qué filas puede ver cada rol —por ejemplo, que support solo vea las órdenes de la tienda a la que está asignado—, no solo qué columnas. Por qué pasa: "control de acceso" es un término amplio, y row-level security (filtrar filas por rol) es una práctica real e igual de común que column-level security (filtrar columnas). Cómo detectarlo: revisa qué recibe y qué devuelve apply_access_policy() — siempre el mismo número de filas que la entrada, nunca menos. Cómo corregirlo: esta lección construye, con toda intención, solo control de acceso a nivel de columna — la pieza que el DISEÑO de esta guía y la auditoría de mercado que la originó marcan explícitamente como el hueco competitivo a cerrar. Row-level security (filtrar filas, no columnas) es una extensión real y razonable, pero queda fuera del alcance de este módulo — el Ejercicio 3 de esta lección te invita a explorarla por tu cuenta.
Pensar que un rol que no aparece en ACCESS_POLICY obtiene acceso a todo, por defecto. Qué pasa: alguien llama a apply_access_policy(df, "admin"), un rol que no está en el diccionario, esperando que devuelva la tabla completa como comportamiento "seguro por defecto". Por qué pasa: en muchos sistemas, "no tengo una regla específica" se interpreta como "sin restricción". Cómo detectarlo: prueba llamar a la función con un rol que no exista — Python lanza KeyError: 'admin', porque policy[role] intenta acceder a una llave que no está en el diccionario. Cómo corregirlo: este comportamiento es, en realidad, el correcto para un sistema de gobierno de datos — el principio de seguridad que la industria llama "deny by default" (denegar por defecto): si un rol no tiene una política explícita, la respuesta correcta es fallar de forma ruidosa (KeyError), no devolver acceso ilimitado en silencio. Un rol nuevo en Kiosko necesita, siempre, una entrada explícita en ACCESS_POLICY antes de poder consultar cualquier tabla gobernada por esta función — nunca acceso por omisión.
Ejercicios
Ejercicio 1 — Corre access_control.py tú mismo, desde cero. En una carpeta nueva, con kiosko.duckdb conteniendo orders_s04 y customers (esta lección), corre python3 access_control.py. Confirma que support ve solo order_id de orders_s04, pero las tres columnas completas de customers.
Ver solución
Si customers tiene las seis filas exactas de esta lección, la salida debería reproducir exactamente la de este ejemplo trabajado: support con una sola columna sobre orders_s04 (['order_id']), y tres columnas completas sobre customers (customer_id, customer_email, customer_phone). Si tu resultado difiere, revisa primero que ACCESS_POLICY["support"] no incluya, por accidente, ninguna columna financiera de orders_s04 — un error común al copiar y ajustar la política de finance.
Ejercicio 2 — Agrega un cuarto rol, "marketing", con acceso de solo lectura a customer_id y customer_email, pero nunca a customer_phone ni a ninguna columna de orders_s04. Extiende ACCESS_POLICY con esta entrada nueva, y confirma que apply_access_policy(customers_df, "marketing") devuelve exactamente dos columnas.
Ver solución
ACCESS_POLICY["marketing"] = ["customer_id", "customer_email"]
view = apply_access_policy(customers_df, "marketing")
print(f"columnas: {view.columns}")
print(view.height, "filas")
Salida esperada:
columnas: ['customer_id', 'customer_email']
6 filas
Fíjate en que esta nueva entrada de marketing es, en su forma exacta, idéntica a la de analyst sobre customers — dos roles de negocio distintos pueden compartir exactamente el mismo nivel de acceso a una tabla específica, sin que eso signifique que sus políticas completas sean idénticas en todas las tablas. ACCESS_POLICY["marketing"] no menciona ninguna columna de orders_s04, así que apply_access_policy(orders_df, "marketing") devolvería un DataFrame sin ninguna columna (shape: (12, 0)) — un resultado técnicamente válido, aunque prácticamente inútil, que documenta con precisión que el rol marketing no tiene ningún acceso a esa tabla.
Ejercicio 3 — Diseña, en prosa (sin escribir código), cómo extenderías ACCESS_POLICY para agregar control a nivel de fila, no solo de columna. Imagina que Kiosko quiere que un rol "store_manager" solo pueda ver las filas de orders_s04 correspondientes a su propia tienda. En 3-4 frases, describe qué estructura de datos adicional necesitarías, y por qué apply_access_policy(), tal como está escrita en esta lección, no puede resolver ese caso sin cambios.
Ver solución
apply_access_policy(), tal como está escrita, opera exclusivamente sobre df.columns —una operación de select(), nunca de filter()—, así que no tiene ninguna forma de decidir qué filas mostrar, sin importar qué tan específica sea la política de columnas. Para resolver el caso de store_manager, necesitarías una estructura adicional que mapeara cada instancia de ese rol a un valor concreto —por ejemplo, un diccionario ROW_LEVEL_POLICY = {"store_manager_s04": {"store_id": "S04"}}— y una segunda función, algo como apply_row_level_policy(df, role_instance, row_policy), que aplicara un df.filter(pl.col("store_id") == row_policy[role_instance]["store_id"]) antes o después del filtro de columnas. La razón por la que esta guía separa las dos ideas —columna y fila— en vez de resolverlas con una sola función es la misma que ya explicó la Profundización de esta lección: cada pieza de gobierno de esta guía busca ser explicable en una sola frase, y "qué columna puedo ver" y "qué filas puedo ver" son, en la práctica, dos preguntas independientes con implementaciones independientes, incluso en sistemas de gobierno de datos de producción real (AWS Lake Formation y Snowflake, por ejemplo, exponen row-level security y column-level security como dos mecanismos separados, no uno solo).
Resumen y siguiente paso
En esta lección conociste customers, la primera tabla de esta guía con datos que identifican a una persona real, y construiste ACCESS_POLICY y apply_access_policy(), la primera pieza del gobierno de datos de Kiosko: control de acceso a nivel de columna, corrido de verdad sobre dos tablas distintas —orders_s04 y customers— con tres roles de negocio, cada uno viendo exactamente lo que necesita para su trabajo, ni más ni menos.
Antes de avanzar deberías poder: explicar la diferencia entre control de acceso a nivel de columna (esta lección) y a nivel de fila (fuera del alcance, discutido en el Ejercicio 3); reproducir, corriendo el código tú mismo, las seis vistas distintas de esta lección (tres roles, dos tablas); y justificar por qué apply_access_policy() falla con KeyError ante un rol desconocido, en vez de devolver acceso ilimitado.
Con esto, Kiosko ya controla qué columna ve cada rol. Pero fíjate en un detalle que la lección dejó sin resolver a propósito: customer_email y customer_phone siguen apareciendo en texto plano para cualquier rol que tenga permitido verlos —ACCESS_POLICY decide si una columna es visible, nunca cómo se ve—. La lección 6 responde esa pregunta pendiente: mask_pii(), con un hash determinista, para que incluso un rol con acceso a una columna sensible pueda trabajar con ella sin ver el valor real.
Recursos
- Polars — documentación oficial (
select,DataFrame.columns— la base deapply_access_policy()). docs.pola.rs. En inglés. - IETF — RFC 2606, "Reserved Top Level DNS Names" (la fuente del dominio
example.com, reservado para documentación y ejemplos, nunca resuelto a un servidor real — la razón por la que esta lección lo usa en los correos decustomers). datatracker.ietf.org/doc/html/rfc2606. En inglés. - AWS — Guía de examen "AWS Certified Data Engineer - Associate (DEA-C01)" (Dominio 4, "Data Security and Governance", 18% del examen). docs.aws.amazon.com/aws-certification. En inglés.
- Microsoft Learn — "Microsoft Certified: Fabric Data Engineer Associate (DP-700)" (la descripción oficial del examen confirma que una de las tres responsabilidades centrales del rol es "Securing and managing an analytics solution" — la evidencia de mercado que ancla el gobierno de columnas de este módulo, junto con AWS DEA-C01). learn.microsoft.com/credentials/certifications/exams/dp-700. En inglés.
- DISEÑO de esta guía — fuente de
customers,ACCESS_POLICYy la frontera con la seguridad de infraestructura real.src/guides/data-reliability-and-governance-guide/DISENO.md. En español.