Plataforma de escaneo, almacenamiento y reporte de permisos de SharePoint Online. Arquitectura hibrida REST + CSOM + Graph con interfaz web interactiva.
SPO Permissions Hub es una plataforma interna que escanea, almacena y presenta informes de permisos de sitios SharePoint Online. Sustituye al uso manual de herramientas comerciales como Syskit, ofreciendo:
La plataforma sigue una arquitectura de 4 capas, comunicadas por NDJSON streaming y SQLite (WAL mode):
Script PowerShell 7 de ~1000 lineas que realiza el escaneo completo de un sitio SPO.
| Funcion | Descripcion |
|---|---|
Write-Msg | Logging dual: stderr (modo JSON) o Write-Host (modo CSV) |
Get-UniqueItemIds | REST $batch API para detectar items con permisos unicos (evita bug CSOM) |
Get-PermissionRows | Extrae RoleAssignments de un objeto securable via CSOM |
Get-SPGroupMembers | Expande grupos SharePoint (con cache) |
Get-AADGroupMembers | Expande grupos Entra ID via Microsoft Graph (con cache) |
Get-UserGraphData | Enriquece datos de usuario desde Graph (departamento, cargo) |
Write-JsonRow | Emite una fila como JSON line a stdout (modo NDJSON) |
Get-PermissionColumn | Mapea niveles de permiso SPO a columnas (ES/EN) |
Modos de autenticacion:
Modos de salida:
-OutputJson: NDJSON a stdout, logs a stderr (para el orchestrator)Proceso Python que coordina los escaneos:
sites en SQLitescans con estado runningsubprocess.Popen con dos threads:
scans.last_progresspermissions en batches de 500--max-workers N (ThreadPoolExecutor)--triggered-by manual) o programado (run_scan.bat)Base de datos SQLite con WAL mode para concurrencia lectura/escritura. Ver seccion 6 para el esquema completo.
Aplicacion Flask con 4 blueprints:
| Blueprint | Ruta | Funcion |
|---|---|---|
| dashboard | / | Vista general con estado de todos los sitios (grid card layout v1.1) |
| reports | /site/<id>/report | Reporte interactivo de permisos por sitio |
| admin | /admin/sites | CRUD de sitios, lanzar scans manuales, historial de scans |
| api | /api/* | API REST: busqueda global, stats, scans en curso |
Endpoints API:
| Endpoint | Descripcion |
|---|---|
GET /api/sites | Lista de sitios con ultimo estado de scan |
GET /api/sites/:id/permissions | Permisos del ultimo scan exitoso |
GET /api/sites/:id/scans | Historial de scans (ultimos 50) |
GET /api/search?q= | Busqueda global por email, nombre, URL, grupo (resultados en tiempo real) |
GET /api/scans/running | Scans en curso con progreso en tiempo real |
GET /api/stats | Estadisticas globales (sitios, scans, usuarios) |
Para cada sitio, el proceso sigue estas fases:
Get-PnPListItem -PageSize 500 obtiene todos los items de cada lista/biblioteca.
Se filtran en PowerShell para quedarse solo con carpetas (FSObjType=1), o todos los items si se usa -IncludeFiles.
Se envian batches de 100 peticiones GET al endpoint _api/$batch de SharePoint, consultando
HasUniqueRoleAssignments para cada item. Esto devuelve un HashSet<int>
con los IDs de items que realmente tienen permisos unicos.
// Ejemplo de sub-request en el batch multipart GET {webUrl}/_api/web/lists(guid'{listId}')/items({itemId})/HasUniqueRoleAssignments HTTP/1.1 Accept: application/json;odata=nometadata // Respuesta por item {"value": true} // o false
Para cada item con permisos unicos, se cargan las RoleAssignments via CSOM
(PnP PowerShell) y se expanden:
Cada permiso se emite como una linea JSON a stdout (NDJSON).
El orchestrator lee stdout del PS1 linea a linea, parsea JSON, y hace
INSERT en batches de 500 filas en la tabla permissions.
Simultaneamente, lee stderr para mostrar progreso en logs y actualizar scans.last_progress.
CREATE TABLE sites ( id INTEGER PRIMARY KEY AUTOINCREMENT, url TEXT NOT NULL UNIQUE, display_name TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1, include_files INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), notes TEXT NOT NULL DEFAULT '' ); CREATE TABLE scans ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id INTEGER NOT NULL REFERENCES sites(id), started_at TEXT NOT NULL DEFAULT (datetime('now')), finished_at TEXT, status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running','success','failed','skipped')), row_count INTEGER, error_message TEXT, ps1_duration_s REAL, triggered_by TEXT NOT NULL DEFAULT 'scheduler', last_progress TEXT NOT NULL DEFAULT '' ); CREATE TABLE permissions ( id INTEGER PRIMARY KEY AUTOINCREMENT, scan_id INTEGER NOT NULL REFERENCES scans(id), site_id INTEGER NOT NULL REFERENCES sites(id), url TEXT, -- URL completa del objeto SPO sharepoint_object TEXT, -- Nombre del objeto object_type TEXT, -- Site|DocumentLibrary|List|Folder|ListItem inherits_permissions INTEGER, -- 0 = permisos unicos name TEXT, -- Nombre del principal email TEXT, -- Email del usuario principal_type TEXT, -- User|SharePointGroup|SecurityGroup|SharingLink given_through TEXT, -- Directly o nombre del grupo department TEXT, -- Desde Microsoft Graph job_title TEXT, -- Desde Microsoft Graph perm_full_control INTEGER, -- Nivel de permiso perm_edit INTEGER, perm_contribute INTEGER, perm_read INTEGER, -- + is_external_user, is_deleted, is_licensed, ... );
scan_id, site_id, email, name, object_type, (email, site_id)
— clave compuesta para busquedas globales por usuario en un sitio concreto.
Al escanear el sitio "idealista" (406K+ items, 65K carpetas en la biblioteca "Documentos"), el scanner reportaba 65,243 carpetas con permisos unicos y tardaba horas en completar. La herramienta comercial Syskit, como referencia, reportaba solo 1,591 carpetas.
Se identificaron dos causas superpuestas:
Tanto Get-PnPListItem -Includes "HasUniqueRoleAssignments" como el enfoque
explicito con $ctx.Load($item, $lambda) + Invoke-PnPQuery devuelven
$true para todos los items cuando se cargan con paginacion
en bibliotecas grandes (>5000 items).
-IncludeInherited en base de datos
El sitio "idealista" tenia include_inherited = 1 en la DB. Esto activaba el parametro
-IncludeInherited en el PS1, que contenia esta linea:
if ($IncludeInherited) { $uniqueItems = $items } # bypass TOTAL del filtro REST
Se reemplazo el chequeo CSOM de HasUniqueRoleAssignments por llamadas a la
REST API via el endpoint _api/$batch de SharePoint:
items({id})/HasUniqueRoleAssignmentsGet-PnPAccessToken -ResourceTypeName SharePointHashSet<int> con IDs de items con permisos unicosreturn ,$uniqueIds
PowerShell "desenrolla" colecciones al retornarlas de funciones. Un HashSet<int>
se convierte en [object[]], perdiendo el metodo .Contains().
La coma unaria (,$var) previene esta enumeracion.
-IncludeInheritedSe elimino la funcionalidad de incluir objetos con permisos heredados de todo el proyecto, ya que nunca se necesita un informe con las ~65K carpetas que heredan permisos:
| Componente | Cambio |
|---|---|
| PS1 (scanner) | Eliminado parametro [switch]$IncludeInherited, help, ejemplo, y 3 bloques if ($IncludeInherited) |
| orchestrator.py | Eliminado de SQL query, parametro de funcion, y paso al PS1 |
| admin.py (webapp) | Eliminado del UPDATE SQL |
| admin_sites.html | Eliminado checkbox "Heredados" y cabecera de tabla |
| schema.sql | Eliminada columna include_inherited |
| SQLite DB | Actualizado site 3: include_inherited = 0 |
| Fix | Detalle |
|---|---|
| Token SharePoint | Get-PnPAccessToken -ResourceTypeName SharePoint en lugar del token Graph por defecto |
| PS7 byte[] Content | Invoke-WebRequest en PS7 devuelve Content como byte[] para multipart/mixed; se agrego conversion a string |
| Regex case-insensitive | Boundary de respuesta batch usa (?i) para compatibilidad |
-MaxFolders N | Parametro de testing para truncar items antes del REST batch |
-IncludeInherited.
El scan fallo con exit code 1 al procesar el item ~600 (timeout o memoria).
include_inherited=1 en la DB del site 3 causaba que el orchestrator
pasara -IncludeInherited al PS1, y la linea
if ($IncludeInherited) { $uniqueItems = $items } saltaba completamente el filtro REST.
-IncludeInherited de todo el proyecto (PS1, orchestrator, webapp, DB, schema).
Se corrigio return ,$uniqueIds para preservar el HashSet.
-MaxFolders 5000 para test parcial. Resultado:
120 unicos de 5,000 verificados, 1,424 filas totales. Confirma que el fix funciona.
| Metrica | Antes (CSOM) | Despues (REST batch) |
|---|---|---|
| Items marcados como "permisos unicos" | 65,243 — 100% falsos positivos | 1,591 — correcto |
| Tiempo de fase de permisos | > 3 horas (y fallaba) | ~25 minutos |
| Tiempo total del scan | > 4 horas (incompleto) | ~70 minutos |
| Resultado final | Crash o timeout | Completo OK |
| Dato | Syskit | SPO Permissions Hub | Match |
|---|---|---|---|
| Carpetas con permisos unicos | 1,591 | 1,591 | OK |
| REST batch count | N/A | 1,591 de 65,247 | OK |
| Fase | Duracion | Detalle |
|---|---|---|
| Conexion | ~2 s | PnP Connect con certificado |
| Enumeracion items | ~7 min | 406,222 items, filtrados a 65,247 carpetas |
| REST $batch pre-filtro | ~36 min | 650 batches de 100, resultado: 1,591 unicos |
| Extraccion permisos | ~25 min (est.) | 1,591 objetos, ~50 items/min |
| Total estimado | ~70 min |
Se evaluo migrar a Microsoft Graph API pero se descarto porque:
HasUniqueRoleAssignmentsMejoras incorporadas tras el lanzamiento inicial, basadas en uso real y feedback de operaciones.