El control de acceso roto está arriba del OWASP Top 10 por algo. Además, es la clase de bug que un equipo de QA está mejor situado para encontrar. No hace falta saber explotar. Hace falta una declaración clara de quién debería poder hacer qué, y tests que comprueben que el sistema está de acuerdo.
La mayoría de las suites de QA ya comprueban que el usuario correcto puede hacer lo correcto. El hueco es el caso negativo: que el usuario equivocado no puede. Esta guía muestra cómo hacer esos casos sistemáticos.
Empieza con una matriz
Anota todos los roles que tiene tu producto y todos los endpoints o acciones que importan. Rellena cada celda con el resultado esperado. Es un ejercicio de requisitos tanto como de pruebas, y a menudo saca a la luz desacuerdos que nadie conocía.
| Endpoint | Anónimo | Viewer | Editor | Admin | Admin de otro tenant |
|---|---|---|---|---|---|
GET /projects/{id} | 401 | 200 | 200 | 200 | 404 |
PATCH /projects/{id} | 401 | 403 | 200 | 200 | 404 |
DELETE /projects/{id} | 401 | 403 | 403 | 204 | 404 |
POST /projects/{id}/members | 401 | 403 | 403 | 201 | 404 |
GET /admin/audit-log | 401 | 403 | 403 | 200 | 403 |
La última columna es la que los equipos olvidan. "Admin de otro tenant" es un usuario con todos los privilegios en otra organización. No debería obtener nada de tus objetos. Muchos fallos de control de acceso solo aparecen en esa columna, porque todas las comprobaciones de rol pasan y solo falta la de propiedad.
Define los actores
El entorno de pruebas necesita un usuario por columna, en al menos dos tenants, más un objeto que pertenezca al primer tenant. Créalos en un fixture para que cada test parta del mismo estado. Usa cuentas de prueba desechables que solo existan en el entorno de pruebas.
# conftest.py
import pytest
from api_client import Client, create_tenant, create_user, create_project
@pytest.fixture(scope="session")
def world():
a = create_tenant("tenant-a")
b = create_tenant("tenant-b")
users = {
"anonymous": None,
"viewer": create_user(a, role="viewer"),
"editor": create_user(a, role="editor"),
"admin": create_user(a, role="admin"),
"other_admin": create_user(b, role="admin"),
}
project = create_project(a, name="qa-fixture")
return {"users": users, "project": project}
def client_for(world, actor):
user = world["users"][actor]
return Client(token=user.token if user else None)
Convierte la matriz en tests
Codifica la matriz como datos y deja que pytest genere un test por celda. Cuando cambia un requisito, cambias una línea de la tabla, no una función de test.
# test_authz_projects.py
import pytest
from conftest import client_for
ACTORS = ["anonymous", "viewer", "editor", "admin", "other_admin"]
MATRIX = {
("GET", "/projects/{id}"): [401, 200, 200, 200, 404],
("PATCH", "/projects/{id}"): [401, 403, 200, 200, 404],
("DELETE", "/projects/{id}"): [401, 403, 403, 204, 404],
("POST", "/projects/{id}/members"): [401, 403, 403, 201, 404],
}
CASES = [
(method, path, actor, expected)
for (method, path), row in MATRIX.items()
for actor, expected in zip(ACTORS, row)
]
@pytest.mark.parametrize("method,path,actor,expected", CASES)
def test_project_authz(world, method, path, actor, expected):
client = client_for(world, actor)
url = path.format(id=world["project"].id)
resp = client.request(method, url, json={"name": "x"})
assert resp.status_code == expected, (
f"{actor} {method} {url}: got {resp.status_code}, want {expected}"
)
Ejecuta las celdas destructivas, como DELETE, contra un objeto nuevo por test, o ponlas al final, para que un borrado que pasa no rompa las celdas siguientes.
Más allá de los códigos de estado
Un código de estado correcto es necesario, pero no suficiente. Añade unas pocas comprobaciones que miren lo que se devuelve:
- Endpoints de listado. Un viewer que lista proyectos ve solo los de su tenant. Haz la aserción sobre los ids, no sobre el número.
- Exposición a nivel de campo. La respuesta para un viewer no incluye campos como datos de facturación o emails de miembros si la matriz dice que no deben verse.
- Mass assignment. Un editor que envía
"role": "admin"o"tenant_id"en el cuerpo de unPATCHno cambia esos campos. - Referencias indirectas. Los objetos hijos como
/projects/{id}/files/{file_id}comprueban que el fichero pertenece a ese proyecto, no solo que el proyecto es accesible.
Qué hacer el lunes
- Elige un recurso, como proyectos o facturas, y escribe su matriz de roles por endpoint junto con producto.
- Añade la columna "otro tenant" y acordad la regla de 403 o 404.
- Crea el fixture con un usuario por columna en dos tenants.
- Codifica la matriz como datos y ejecútala en CI en cada build.
- Añade una fila nueva cada vez que una historia añada un endpoint. Enlázala con el caso de abuso de la historia, para que el requisito y el test vayan juntos.