📋 Informe de Proyecto — Plataforma Interna

SPO Permissions Hub

Plataforma de escaneo, almacenamiento y reporte de permisos de SharePoint Online. Arquitectura hibrida REST + CSOM + Graph con interfaz web interactiva.

19 marzo 2026
JM Fernandez + Claude (Anthropic)
Version 1.1
⚡ PowerShell 7 🐍 Python 3.12 🗄 SQLite WAL 🕸 Flask 3.x 🌐 Microsoft Graph ☁ SharePoint REST ✅ v1.1 actualizado
Contenido
  1. 01Objetivo del Proyecto
  2. 02Arquitectura
  3. 03Estructura de Ficheros
  4. 04Componentes Detallados
  5. 05Flujo de Datos
  6. 06Modelo de Datos
  7. 07Problema Detectado: Bug CSOM
  8. 08Solucion Implementada
  9. 09Cronologia de Fixes
  10. 10Resultados y Verificacion
  11. 11Novedades v1.1

🎯 Objetivo del Proyecto 01

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:

🤖
Escaneo Automatizado
Escaneo de multiples sitios SPO mediante un orquestador programable con soporte paralelo.
🔍
Deteccion de Permisos Unicos
Identifica permisos no heredados en sitios, listas, carpetas y archivos con alta precision.
👥
Expansion de Grupos
Expande grupos SharePoint, grupos Entra ID (Azure AD) y enlaces de comparticion completos.
Enriquecimiento con Graph
Datos de departamento y cargo obtenidos desde Microsoft Graph para cada usuario.
📊
Dashboard Interactivo
Interfaz web con dashboard en grid, busqueda global en tiempo real y reportes por sitio.
🔗
API REST + CSV Syskit
API REST para integracion con otros sistemas y compatibilidad de formato con Syskit.

🏛 Arquitectura 02

La plataforma sigue una arquitectura de 4 capas, comunicadas por NDJSON streaming y SQLite (WAL mode):

🔍
Scanner
PowerShell 7
PnP.PowerShell
Orchestrator
Python 3.12
subprocess / threads
💾
Database
SQLite 3
WAL mode
🌐
Web App
Flask 3.x
Jinja2 + JS
PS1 escanea SPO NDJSON stdout Orchestrator procesa INSERT SQLite Web App muestra

📁 Estructura de Ficheros 03

spo-permissions-hub/
  scanner/
    Get-SPOPermissionsReport.ps1 — Scanner principal (1012 lineas)
    spo-report-config.json — Config auth (ClientId, Tenant, Thumbprint)
  orchestrator/
    orchestrator.py — Orquestador de scans (267 lineas)
    importer.py — Importador JSON → SQLite (52 lineas)
    run_scan.bat — Launcher para Task Scheduler
  db/
    schema.sql — Esquema DDL (3 tablas, 6 indices)
    database.py — Conexion y migraciones (44 lineas)
    spo_permissions.db — Base de datos SQLite
  webapp/
    app.py — Flask application factory
    routes/
      dashboard.py — Vista principal con estado de sitios (grid layout v1.1)
      reports.py — Reportes de permisos por sitio
      admin.py — Gestion de sitios y lanzamiento de scans
      api.py — API REST (busqueda, stats, scans)
    templates/ — Plantillas Jinja2 (dashboard, report, admin, search, base)
    static/ — CSS + JS del report interactivo
  config/
    app-config.json — Config de la webapp (puerto, debug, rutas)
  logs/ — Logs por sitio y del orquestador
  requirements.txt — flask>=3.0

🧩 Componentes Detallados 04

4.1 Scanner (Get-SPOPermissionsReport.ps1)

Script PowerShell 7 de ~1000 lineas que realiza el escaneo completo de un sitio SPO.

FuncionDescripcion
Write-MsgLogging dual: stderr (modo JSON) o Write-Host (modo CSV)
Get-UniqueItemIdsREST $batch API para detectar items con permisos unicos (evita bug CSOM)
Get-PermissionRowsExtrae RoleAssignments de un objeto securable via CSOM
Get-SPGroupMembersExpande grupos SharePoint (con cache)
Get-AADGroupMembersExpande grupos Entra ID via Microsoft Graph (con cache)
Get-UserGraphDataEnriquece datos de usuario desde Graph (departamento, cargo)
Write-JsonRowEmite una fila como JSON line a stdout (modo NDJSON)
Get-PermissionColumnMapea niveles de permiso SPO a columnas (ES/EN)

Modos de autenticacion:

Modos de salida:

4.2 Orchestrator (orchestrator.py)

Proceso Python que coordina los escaneos:

4.3 Database (SQLite)

Base de datos SQLite con WAL mode para concurrencia lectura/escritura. Ver seccion 6 para el esquema completo.

4.4 Web Application (Flask)

Aplicacion Flask con 4 blueprints:

BlueprintRutaFuncion
dashboard/Vista general con estado de todos los sitios (grid card layout v1.1)
reports/site/<id>/reportReporte interactivo de permisos por sitio
admin/admin/sitesCRUD de sitios, lanzar scans manuales, historial de scans
api/api/*API REST: busqueda global, stats, scans en curso

Endpoints API:

EndpointDescripcion
GET /api/sitesLista de sitios con ultimo estado de scan
GET /api/sites/:id/permissionsPermisos del ultimo scan exitoso
GET /api/sites/:id/scansHistorial de scans (ultimos 50)
GET /api/search?q=Busqueda global por email, nombre, URL, grupo (resultados en tiempo real)
GET /api/scans/runningScans en curso con progreso en tiempo real
GET /api/statsEstadisticas globales (sitios, scans, usuarios)

🔄 Flujo de Datos Completo 05

Para cada sitio, el proceso sigue estas fases:

Fase 1: Enumeracion (~10 min para 400K items)

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.

Fase 2: Pre-filtro REST $batch (~36 min para 65K carpetas)

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

Fase 3: Extraccion de permisos CSOM (~25 min para 1591 items)

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).

Fase 4: Ingesta y almacenamiento

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.

🗄 Modelo de Datos 06

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, ...
);
Indices: scan_id, site_id, email, name, object_type, (email, site_id) — clave compuesta para busquedas globales por usuario en un sitio concreto.

🐛 Problema Detectado: Bug CSOM HasUniqueRoleAssignments 07

Sintoma

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.

406K+
Items totales en la biblioteca Documentos
65,243
Carpetas (FSObjType=1) enumeradas
65,243
CSOM reportaba como "permisos unicos" (falso)
1,591
Realidad confirmada por Syskit y REST API

Causa raiz

Se identificaron dos causas superpuestas:

Causa 1: Bug CSOM en bibliotecas grandes

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).

Es un comportamiento conocido del servidor SharePoint: el security scope no se computa individualmente para items obtenidos via batch enumeration CSOM. La REST API usa un code path diferente en el servidor y devuelve valores correctos.

Causa 2: Flag -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
Esto hacia que incluso despues de implementar el fix REST, se siguieran procesando las 65K carpetas porque el filtro se saltaba completamente.

🔧 Solucion Implementada 08

Fix 1: REST $batch API como pre-filtro

Se reemplazo el chequeo CSOM de HasUniqueRoleAssignments por llamadas a la REST API via el endpoint _api/$batch de SharePoint:

Fix 2: return ,$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.

Fix 3: Eliminacion completa de -IncludeInherited

Se 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:

ComponenteCambio
PS1 (scanner)Eliminado parametro [switch]$IncludeInherited, help, ejemplo, y 3 bloques if ($IncludeInherited)
orchestrator.pyEliminado de SQL query, parametro de funcion, y paso al PS1
admin.py (webapp)Eliminado del UPDATE SQL
admin_sites.htmlEliminado checkbox "Heredados" y cabecera de tabla
schema.sqlEliminada columna include_inherited
SQLite DBActualizado site 3: include_inherited = 0

Fix 4: Otros fixes tecnicos

FixDetalle
Token SharePointGet-PnPAccessToken -ResourceTypeName SharePoint en lugar del token Graph por defecto
PS7 byte[] ContentInvoke-WebRequest en PS7 devuelve Content como byte[] para multipart/mixed; se agrego conversion a string
Regex case-insensitiveBoundary de respuesta batch usa (?i) para compatibilidad
-MaxFolders NParametro de testing para truncar items antes del REST batch

📅 Cronologia de Fixes 09

scan_id=12 — 10:37 - 10:50
Primer scan con REST batch Parcial
REST batch funciono (0 unicos de 65,243 verificados — pero se usaba token Graph incorrecto, devolviendo 0 en todas las respuestas). 64 filas emitidas (solo permisos de listas, no items).
Fixes aplicados
Token SharePoint + byte[] Content + regex
Se corrigio el tipo de token, la decodificacion de la respuesta multipart en PS7, y la regex de parseo del boundary.
scan_id=13 — 11:07 - 12:05
REST batch correcto, pero sigue procesando 65K Fallo
REST batch ahora devuelve correctamente 1,591 unicos. Pero la fase de permisos inicia con "65,243 objetos con permisos unicos" — el filtro se bypaseaba por -IncludeInherited. El scan fallo con exit code 1 al procesar el item ~600 (timeout o memoria).
Diagnostico
Descubrimiento de la causa raiz real
Se identifico que 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.
Fixes aplicados
Eliminacion de IncludeInherited + return ,$uniqueIds
Se elimino -IncludeInherited de todo el proyecto (PS1, orchestrator, webapp, DB, schema). Se corrigio return ,$uniqueIds para preservar el HashSet.
scan_id=14 — 12:06 - 13:03
Scan interrumpido Interrumpido
Se lanzo el scan completo. REST batch correcto (1,591 unicos). Fase de permisos iniciada correctamente con 1,591 items. Se interrumpio en item 350 para lanzar un test rapido.
Test MaxFolders=5000
Test rapido exitoso OK
Se agrego -MaxFolders 5000 para test parcial. Resultado: 120 unicos de 5,000 verificados, 1,424 filas totales. Confirma que el fix funciona.
scan_id=15 — 13:33 - en curso
Scan completo final Completado
REST batch: 1,591 con permisos unicos de 65,247 verificados. Fase de permisos: procesando 1,591 objetos (no 65K). Todo funciona segun lo esperado.

📊 Resultados y Verificacion 10

Comparativa: Antes vs Despues

MetricaAntes (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

Validacion contra Syskit

DatoSyskitSPO Permissions HubMatch
Carpetas con permisos unicos 1,591 1,591 OK
REST batch count N/A 1,591 de 65,247 OK

Desglose del scan (scan_id=15)

FaseDuracionDetalle
Conexion~2 sPnP Connect con certificado
Enumeracion items~7 min406,222 items, filtrados a 65,247 carpetas
REST $batch pre-filtro~36 min650 batches de 100, resultado: 1,591 unicos
Extraccion permisos~25 min (est.)1,591 objetos, ~50 items/min
Total estimado~70 min

Decision de diseno: Por que no Graph API

Se evaluo migrar a Microsoft Graph API pero se descarto porque:

El resultado es un enfoque hibrido optimo: REST $batch para deteccion de permisos unicos, CSOM para extraccion de RoleAssignments, y Graph para datos de usuario.

Novedades v1.1 11

Mejoras incorporadas tras el lanzamiento inicial, basadas en uso real y feedback de operaciones.

📊
Dashboard RediseƱado
Nueva vista en grid con tarjetas por sitio, formateo de fechas mejorado y barra de progreso en tiempo real para scans en ejecucion. Lectura del estado de cada sitio de un vistazo.
UI/UX
📆
Popup de Calendario
Cuando varios scans coinciden en el mismo dia, un popup de calendario permite seleccionar y comparar cada ejecucion individual sin perderse en el historial.
UI/UX
🔍
Busqueda Global
Nueva funcionalidad de busqueda en tiempo real que cubre todos los sitios simultaneamente. Busca por email, nombre de usuario, URL, grupo o tipo de permiso.
Funcionalidad
Panel de Administracion
Panel admin completo para gestionar sitios (alta/baja/edicion), lanzar scans manuales en cualquier momento y consultar el historial de ejecuciones con todos los detalles.
Funcionalidad
🔧
Fix FK en tabla permissions
Correccion de integridad referencial en la tabla de permisos tras operaciones de rebuild de scans. Las claves foraneas corrompidas se detectan y reparan durante la migracion.
Bugfix DB
🏎
Migracion y Race Condition
Solucion a la condicion de carrera en la migracion de la tabla scans cuando multiples procesos arrancan simultaneamente. Migraciones idempotentes con bloqueo de escritura.
Bugfix
Resumen de commits v1.1: Dashboard redesign (grid layout, date formatting, progress strip) • Calendar popup for multiple scans on same day • Fix corrupted FK in permissions table after scans rebuild • Database migration fixes • Race condition fix in scans table migration • Search functionality across all sites • Admin panel for managing sites and scan history