Skip to content

Instantly share code, notes, and snippets.

@fforres
Last active March 18, 2026 08:23
Show Gist options
  • Select an option

  • Save fforres/9c7228ee2113944c28255326a21adc7e to your computer and use it in GitHub Desktop.

Select an option

Save fforres/9c7228ee2113944c28255326a21adc7e to your computer and use it in GitHub Desktop.
Pentest Documentation

Documentación de arquitectura para pentesting

Contexto

Skyward es una plataforma de Governance, Risk and Compliance (GRC) construida como un monorepo desplegado en Cloudflare Workers. La arquitectura sigue un modelo local-first con sincronización en tiempo real mediante WebSockets.

Objetivo

Este documento resume la arquitectura técnica relevante para un ejercicio de pentesting sobre la plataforma. El foco está puesto en los límites de confianza, los componentes expuestos, los canales de comunicación en tiempo real y los activos que participan en autenticación, autorización y persistencia.

Acceso

URL: https://app.skyward.ai/

Users y Contraseñas (Enviados por correo)

Summary

La plataforma está organizada como un monorepo desplegado principalmente sobre Cloudflare Workers, con un patrón local-first para el dashboard basado en Zero Sync. Además, existen Cloudflare Durable Objects para agentes de larga duración y Cloudflare Workflows para tareas asíncronas.

Desde una perspectiva de seguridad, los componentes más relevantes son:

  • Dashboard: cliente React que consume la API y mantiene un canal de sincronización con Zero.
  • API: Worker Hono que expone endpoints HTTP, tRPC legado, endpoints de Zero (/z/*), archivos y proxy hacia agentes.
  • Zero Sync: capa de sincronización en tiempo real con almacenamiento local en cliente y reconciliación contra backend.
  • Agents service: Worker dedicado que expone agentes por WebSocket y ejecuta lógica stateful sobre Durable Objects.
  • Workflows: procesos asíncronos que realizan tareas de larga duración y side effects.
  • PostgreSQL / réplica Zero: almacenamiento transaccional principal y la infraestructura de sincronización/réplica usada por Zero.

Stack Tecnológico

Componente Tecnología
Runtime Cloudflare Workers
Frontend React 19, React Router v7, Tailwind CSS v4
API Hono (REST)
Database PostgreSQL + Drizzle ORM
AI/LLM Vercel AI SDK + OpenAI
Authentication WorkOS
Agents Cloudflare Durable Objects
Background Jobs Cloudflare Workflows
Real-time Sync Zero Sync (Rocicorp) (Lo unico que no está en cloudflare)

Componentes y límites de confianza

1. Cliente web

El dashboard corre en el navegador y mantiene tres interacciones relevantes:

  1. HTTP autenticado hacia la API para obtener sesión, token de Zero, archivos y endpoints auxiliares.
  2. Sync engine de Zero para sincronización reactiva y sockets administrados por la librería de Zero (https://zero.rocicorp.dev/docs/queries).
  3. WebSocket de agentes a través de la API hacia el servicio de agentes, hosteado por Cloudflare Workers (Durable objects), enviando token y contexto de tracing en la conexión.

2. API pública

La API centraliza:

  • CORS y validaciones de origen.
  • Endpoints de zero /z/get-queries, /z/pull, /z/mutate, /z/push
  • Proxy en la ruta agents/* hacia el servicio dedicado de agentes.
  • Endpoints de archivos, webhooks, tRPC y 1 REST endpoint.

3. Servicio de agentes

El servicio de agentes está separado del API Worker principal, API Worker funciona como Proxy de conexión al servico de Agents, el que recibe conexiones WebSocket. Antes de aceptar una conexión se valida que el agente solicitado esté dentro de un allowlist y extrae el token JWT desde query params para observabilidad y contexto.

4. Zero Sync

Información importante de Zero: https://zero.rocicorp.dev/docs/queries

Zero mantiene una copia local de datos en el cliente y sincroniza por canales persistentes con el backend. Las mutaciones pueden ocurrir de forma optimista en cliente y luego confirmarse en servidor. El modelo de confianza depende de:

  • un JWT emitido por la API de Skyward (trpc, mediante zeroToken).
  • Contexto de usuario/organización embebido en ese token.
  • Filtros de tenant y permisos incluidos en queries sincronizadas y mutators.
  • Endpoints mutate y get-queries de la API.

5. Persistencia y procesos internos

  • PostgreSQL es la fuente de verdad transaccional.
  • Zero usa servicios de réplica/sincronización para mantener el motor de vistas sincronizadas.
  • Mutadores "compartidos" se ejecutan del lado del, y los server mutators agregan side effects diferidos, (logs, notificaciones, envios de correos, y automatizaciones)

Diagrama de arquitectura

flowchart LR
    user[Usuario autenticado]

    subgraph browser[Browser / Dashboard React]
        dashboard[Dashboard]
        localdb[(Cache local Zero\nmem/idb)]
        agentHook[Hooks de agentes]
    end

    subgraph edge[Cloudflare Edge]
        api[API Worker\nHono]
        agentsProxy[Proxy /agents/*]
        agentsSvc[Agents Worker\nDurable Objects]
        workflows[Workflows Worker]
    end

    subgraph zeroInfra[Zero Sync Infra]
        zeroServer[Zero Sync Server]
        replication[Replication Manager]
        viewSyncer[View Syncer]
        replica[(Replica / CVR / Change DB)]
    end

    subgraph data[Data Layer]
        postgres[(PostgreSQL)]
        files[(Object storage / files)]
        external[Servicios externos\nSlack / email / terceros]
    end

    user --> dashboard

    dashboard -->|HTTPS + cookies / JWT| api
    dashboard -->|Sync engine / sockets| zeroServer
    dashboard --> localdb
    agentHook -->|WebSocket con token| api
    api --> agentsProxy
    agentsProxy --> agentsSvc

    api -->|/z/get-queries y /z/mutate| zeroServer
    zeroServer --> replica
    zeroServer --> postgres
    replication --> postgres
    replication --> replica
    viewSyncer --> replica
    viewSyncer --> postgres

    api --> postgres
    api --> files
    api --> external
    agentsSvc --> workflows
    workflows --> postgres
    workflows --> files
    workflows --> external
    agentsSvc --> external
Loading

Superficies de ataque prioritarias

Autenticación y sesión

  • Cookie de sesión del dashboard. (Emitida por WorkOS)
  • Emisión de zeroToken para sincronización. (Emitida por API Skyward)
  • Uso de token en query string para agentes WebSocket.

Activos sensibles

  • JWT de Zero con claims de usuario, organización y privilegios.
  • Cookies de sesión del dashboard.
  • Datos sincronizados localmente en IndexedDB/memoria del navegador.
  • Contenido documental, evidencias, resultados de agentes y reportes.
  • Datos multi-tenant almacenados en PostgreSQL.
  • Señales operacionales enviadas a observabilidad.

Supuestos útiles para el pentesting

  1. La aplicación utiliza sincronización reactiva y por tanto el cliente puede retener datos localmente más tiempo que una app HTTP tradicional.
  2. La autorización no vive en un solo punto: se reparte entre tokens, synced queries, mutators compartidos, server mutators y permisos reactivos del dashboard.
  3. Los agentes y Durable Objects introducen una segunda superficie stateful distinta del motor de sincronización Zero.

Recomendaciones de alcance para el proveedor de pentesting

Se recomienda incluir explícitamente:

  • autenticación y administración de sesión,
  • autorización multi-tenant,
  • pruebas sobre Zero Sync y su canal de sockets,
  • pruebas sobre Durable Objects y WebSockets de agentes,
  • revisión de exposición de archivos/evidencias,
  • validación de SSRF, IDOR, privilege escalation y replay en endpoints asíncronos,
  • validación de persistencia local de datos sensibles en navegador.

Flujos Críticos y Sistema de Permisos

1. Flujo de Autenticación

Descripción

La autenticación utiliza WorkOS como Identity Provider con OAuth2/OIDC. El flujo soporta tanto sesiones de cookies como tokens JWT para APIs.

Diagrama de Autenticación

sequenceDiagram
    autonumber
    participant User as Usuario
    participant Browser as Browser
    participant Dashboard as Dashboard Worker
    participant WorkOS as WorkOS IdP
    participant API as API Worker
    participant DB as PostgreSQL

    User->>Browser: Accede a /login
    Browser->>Dashboard: GET /login
    Dashboard->>WorkOS: Redirect to OAuth authorize
    WorkOS->>User: Muestra login form
    User->>WorkOS: Ingresa credenciales
    WorkOS->>WorkOS: Valida credenciales
    WorkOS->>Dashboard: Callback con authorization code
    Dashboard->>WorkOS: Exchange code for tokens
    WorkOS-->>Dashboard: Access Token + Refresh Token
    Dashboard->>Dashboard: Crea sealed session cookie
    Dashboard->>API: Valida user en DB
    API->>DB: SELECT user WHERE workos_id
    DB-->>API: User record
    API-->>Dashboard: User validated
    Dashboard-->>Browser: Set-Cookie (wos-session)
    Browser->>User: Redirect to /dashboard
Loading

Componentes de Autenticación

Componente Descripción
Session Storage Manejo de cookies cifradas
AuthKit Loader Integración WorkOS AuthKit
JWT Validation Verificación mediante JWKS

Tokens y Sesiones

flowchart TD
    subgraph SessionTypes["Tipos de Sesión"]
        Cookie["Sealed Cookie<br/>(wos-session)"]
        JWT["JWT Access Token"]
    end

    subgraph Validation["Validación"]
        Cookie -->|"Decrypt with COOKIE_PASSWORD"| SealedSession["Sealed Session"]
        SealedSession -->|"Extract"| UserData["User Data"]

        JWT -->|"Fetch JWKS"| JWKS["WorkOS JWKS"]
        JWKS -->|"Verify signature"| JWTPayload["JWT Payload"]
        JWTPayload -->|"Extract sub"| UserID["User ID"]
    end

    subgraph Usage["Uso"]
        UserData -->|"Dashboard SSR (Only allows dashboard access, no API access)"| DashboardAuth
        UserID -->|"API Calls"| APIAuth
    end
Loading

2. Flujos de Zero Sync (Real-time Synchronization)

Descripción

Zero Sync es un motor de sincronización local-first que mantiene una copia local de los datos en el cliente (IndexedDB) y sincroniza cambios bidireccionalmente via WebSocket.

Diagrama de Conexión Zero Sync

sequenceDiagram
    autonumber
    participant Client as Dashboard Client
    participant IDB as IndexedDB
    participant ZeroClient as Zero Client
    participant WSS as WebSocket
    participant ZeroServer as Zero Cache Server
    participant API as API Worker
    participant DB as PostgreSQL

    Client->>ZeroClient: Initialize with JWT
    ZeroClient->>WSS: Connect WebSocket
    WSS->>ZeroServer: Establish connection
    ZeroServer->>ZeroServer: Validate JWT
    ZeroServer-->>WSS: Connection accepted
    WSS-->>ZeroClient: Connected

    Note over Client,DB: Initial Sync
    ZeroClient->>ZeroServer: Request synced queries
    ZeroServer->>DB: Execute queries with permissions
    DB-->>ZeroServer: Query results
    ZeroServer-->>ZeroClient: Sync data
    ZeroClient->>IDB: Store locally

    Note over Client,DB: Mutation Flow
    Client->>ZeroClient: mutate(action)
    ZeroClient->>IDB: Optimistic update
    ZeroClient->>ZeroServer: Push mutation
    ZeroServer->>API: Execute server mutator
    API->>DB: Apply changes
    DB-->>API: Commit result
    API-->>ZeroServer: Success/Error
    ZeroServer-->>ZeroClient: Confirm/Rollback
    ZeroClient->>IDB: Confirm or revert
Loading

Arquitectura de Mutadores

flowchart TB
    subgraph Client["Cliente (Dashboard)"]
        UI["React Component"]
        ZeroHook["useZero() Hook"]
        SharedMutator["Shared Mutator<br/>(packages/zero-sync)"]
    end

    subgraph Server["Servidor (API Worker)"]
        ServerMutator["Server Mutator<br/>(apps/api/server-mutators)"]
        SideEffects["Side Effects"]
        ActivityLog["Activity Logging"]
        Notifications["Notifications"]
    end

    subgraph Database["Base de Datos"]
        PG[("PostgreSQL")]
    end

    UI -->|"z.mutate(...)"| ZeroHook
    ZeroHook -->|"Optimistic"| SharedMutator
    SharedMutator -->|"via Zero Protocol"| ServerMutator
    ServerMutator -->|"sharedMutators.fn()"| SharedMutator
    ServerMutator -->|"postCommitTasks"| SideEffects
    SideEffects --> ActivityLog
    SideEffects --> Notifications
    ServerMutator -->|"tx.mutate"| PG
Loading

Synced Queries y Permisos

Zero usa "shared queries" para decidir que información sincronizar, una definicion simple de una query en Skyward junto a los filtros que zero aplicaría sería esto:

flowchart LR
    subgraph SyncedQuery["Synced Query Definition"]
        Query["zql.controls<br/>.where('organization_id', '=', ctx.organizationId)<br/>.where('deleted_at', 'IS', null)"]
    end

    subgraph Filters["Filtros Aplicados"]
        OrgFilter["Organization Isolation"]
        SoftDelete["Soft Delete Filter"]
        PermFilter["Permission Checks"]
    end

    Query --> OrgFilter
    OrgFilter --> SoftDelete
    SoftDelete --> PermFilter
    PermFilter --> Results["Datos Sincronizados"]
Loading

Consideraciones de Seguridad Zero Sync

  1. JWT Required: Conexión WebSocket requiere token válido.
  2. Organization Isolation: Todos los queries filtran por organization_id
  3. Server Validation: Mutaciones se validan en servidor antes de persistir
  4. Optimistic Rollback: Cambios se revierten si servidor rechaza

3. Flujo de Agents (Durable Objects + WebSocket)

Descripción

Los Agents son al fina l Cloudflare Durable Objects que mantienen estado persistente y se comunican via WebSocket para interacciones en tiempo real.

Diagrama de Conexión a Agent

sequenceDiagram
    autonumber
    participant Client as Dashboard
    participant Gateway as Agents Gateway
    participant Middleware as hono-agents Middleware
    participant DO as Durable Object
    participant LLM as OpenAI API
    participant DB as PostgreSQL

    Client->>Gateway: WebSocket upgrade<br/>GET /agents/chat/{id}?token=JWT
    Gateway->>Middleware: onBeforeConnect hook
    Middleware->>Middleware: Validate agent name in allowedAgents
    Middleware->>Middleware: Decode JWT, set Sentry context
    Middleware-->>Gateway: Allow connection
    Gateway->>DO: Route to Durable Object
    DO->>DO: onConnect() lifecycle
    DO->>DB: Load conversation context
    DB-->>DO: Conversation data

    Note over Client,DB: Chat Message Flow
    Client->>DO: Send message via WebSocket
    DO->>DO: onChatMessage()
    DO->>LLM: streamText() with tools
    LLM-->>DO: Streaming response
    DO-->>Client: Stream chunks via WebSocket
    DO->>DB: Persist conversation
Loading

Arquitectura de Durable Objects

AKA, algunos de los agentes que contamos en Skyward (No todos)

flowchart TB
    subgraph Gateway["Agents Gateway (apps/agents/index.ts)"]
        Hono["Hono App"]
        AgentsMiddleware["agentsMiddleware()"]
        AllowedAgents["allowedAgents Set"]
    end

    subgraph DurableObjects["Durable Objects"]
        Chat["_Chat<br/>(AIChatAgent)"]
        RiskMatrix["RiskMatrixDeconstructionAgent"]
        KYC["Global66KYCAgent"]
        Policy["PolicyAnalysisAgent"]
        Control["ControlAnalysisAgent"]
    end

    subgraph Lifecycle["Lifecycle Methods"]
        onStart["onStart()"]
        onConnect["onConnect()"]
        onMessage["onChatMessage()"]
        onClose["onClose()"]
    end

    Hono --> AgentsMiddleware
    AgentsMiddleware -->|"Validate"| AllowedAgents
    AgentsMiddleware -->|"Route"| DurableObjects

    Chat --> Lifecycle
    Chat -->|"Uses"| Tools["AI Tools"]
    Tools --> SearchTool["Search Agent"]
    Tools --> PlannerTool["Planner"]
    Tools --> EvaluatorTool["Evaluator"]
Loading

Agents Permitidos (Whitelist)

(Idem, algunos de los agentes permitidos)

const allowedAgents = new Set([
  'risk-matrix-analysis-agent',
  'chat',
  'browser-agent',
  'global66-kyc-agent',
  'policy-analysis-agent',
  'risk-matrix-deconstruction-agent',
  'control-analysis-agent',
  'control-deduplication-agent',
])

Autenticación de Agents

Siguiendo el mismo approach que para ZERO, usamos la JWT creada por la API de Skyward, enviada como un query-param a la conexión del socket para validar acceso.

flowchart TD
    Request["WebSocket Request<br/>/agents/{name}/{id}?token=JWT"]

    subgraph Validation["Validación"]
        ExtractToken["Extraer token de query params"]
        DecodeJWT["Decodificar JWT"]
        ValidateAgent["Validar agent en allowedAgents"]
        SetContext["Set Sentry user context"]
    end

    subgraph Result["Resultado"]
        Allow["Permitir conexión"]
        Deny["Denegar (403)"]
    end

    Request --> ExtractToken
    ExtractToken --> DecodeJWT
    DecodeJWT --> ValidateAgent
    ValidateAgent -->|"Agent existe"| SetContext
    ValidateAgent -->|"Agent no existe"| Deny
    SetContext --> Allow
Loading

Soft Delete Pattern

Para facilitar el análisis de compliance utilizamos un patrón de soft delete.

flowchart LR
    subgraph Mutation["Delete Mutation"]
        DeleteCall["mutators.controls.delete()"]
    end

    subgraph Update["Database Update"]
        SetDeletedAt["SET deleted_at = NOW()"]
    end

    subgraph Sync["Zero Sync"]
        SyncedQuery["WHERE deleted_at IS NULL"]
        LocalDB["IndexedDB"]
    end

    subgraph Result["Result"]
        ClientView["Client: Record disappears"]
        ServerView["Server: Record preserved"]
    end

    DeleteCall --> SetDeletedAt
    SetDeletedAt --> SyncedQuery
    SyncedQuery -->|"Filter out"| LocalDB
    LocalDB --> ClientView
    SetDeletedAt -->|"Preserved"| ServerView
Loading

4. Flujos de Aplicaci´øn

Flujo: Permisos reactivos sobre controles y validaciones

El dashboard expone permisos reactivos para operaciones sobre controles. Estos permisos se evalúan localmente con datos ya sincronizados y luego deben ser consistentes con las verificaciones del servidor.

Ejemplos documentados:

  • ver control,
  • editar control,
  • subir evidencias,
  • reiniciar análisis,
  • solicitar revisión,
  • cancelar revisión,
  • eliminar control,
  • eliminar riesgo.

Diagrama

flowchart TD
    subgraph client[Cliente con datos sincronizados]
        permQuery[Synced query de permiso]
        localData[Datos del control y validaciones]
        decision{Permiso local}
    end

    subgraph server[Servidor]
        mutator[Mutator]
        guard[Assertions de organización / rol / owner]
        db[(PostgreSQL)]
    end

    permQuery --> localData
    localData --> decision
    decision -->|habilita UI| mutator
    mutator --> guard
    guard --> db
Loading

Flujo: carga y borrado de evidencias

Descripción

El flujo de evidencias es sensible porque combina archivos, ownership, validaciones de estado y escritura de varias entidades relacionadas.

  1. El usuario intenta subir o eliminar evidencia de una validación de control.
  2. El mutator valida pertenencia a organización.
  3. Obtiene el control asociado y verifica admin u owner.
  4. Revisa el estado de la validación antes de permitir edición.
  5. Inserta o marca como borradas entidades de documentos, versiones y relaciones con evidencia.

Diagrama

sequenceDiagram
    participant U as Usuario
    participant D as Dashboard
    participant API as API /z/mutate
    participant M as control_evidences mutator
    participant DB as PostgreSQL
    participant FS as Storage de archivos

    U->>D: Solicita subir o borrar evidencia
    D->>API: Mutación con controlValidationId y organizationId
    API->>M: Ejecuta mutator
    M->>DB: Verifica membership y control asociado
    M->>DB: Verifica admin u owner
    M->>DB: Verifica estado de approval
    alt Subida
        M->>DB: Inserta document / version / evidence_control
        M->>FS: Referencia fileId previamente cargado
    else Borrado lógico
        M->>DB: Marca deleted_at en evidence_control
    end
    API-->>D: Resultado
Loading

Flujo: solicitudes de aprobación

Descripción

Las aprobaciones son críticas porque cambian estados de compliance y pueden disparar side effects. Los mutators de approvals verifican organización, existencia de la versión objetivo y ausencia de aprobaciones pendientes duplicadas. En algunos flujos, además validan que el usuario aprobador pertenezca a la organización.

Diagrama

sequenceDiagram
    participant D as Dashboard
    participant API as API /z/mutate
    participant A as approvalMutators
    participant DB as PostgreSQL

    D->>API: Solicita approval para policy/document version
    API->>A: requestForPolicyVersion o requestForDocumentVersion
    A->>DB: Valida membership del solicitante
    A->>DB: Valida que el recurso pertenezca al tenant
    A->>DB: Busca approvals pendientes existentes
    alt Sin approval previo
        A->>DB: Inserta approval
        API-->>D: Éxito
    else Duplicado
        API-->>D: Error
    end
Loading

Flujo: conexión a agentes por Durable Objects y WebSockets

Descripción

El dashboard usa useAgent para abrir una conexión WebSocket contra la API. Esa conexión incluye en query params:

  • token,
  • organizationId,
  • contexto de tracing.

La API reenvía agents/* al servicio de agentes. El Worker de agentes valida si el nombre del agente pertenece a un allowlist antes de aceptar la conexión. Luego el agente ejecuta lógica stateful en Durable Objects y puede interactuar con workflows y sistemas externos.

Diagrama

sequenceDiagram
    participant D as Dashboard
    participant API as API Worker
    participant AG as Agents Worker
    participant DO as Durable Object Agent
    participant WF as Workflow / servicios externos

    D->>API: WebSocket /agents/{agent}?token=...&organizationId=...
    API->>AG: Proxy de la conexión
    AG->>AG: Valida agent allowlist
    AG->>AG: Decodifica token para contexto y observabilidad
    AG->>DO: Entrega conexión al Durable Object
    DO-->>D: Mensajes de estado
    DO->>WF: Ejecuta tareas largas / side effects
Loading

Flujo: side effects diferidos y workflows

Descripción

Los server mutators pueden ejecutar la lógica compartida y luego encolar postCommitTasks para efectos secundarios como logs, notificaciones o disparo de workflows. Esto crea una segunda fase después de la transacción principal.

Diagrama

flowchart LR
    client[Cliente] --> api[API / server mutator]
    api --> tx[Transacción principal]
    tx --> db[(PostgreSQL)]
    api --> tasks[postCommitTasks]
    tasks --> notify[Notificaciones]
    tasks --> workflow[Cloudflare Workflows]
    workflow --> external[Servicios externos]
    workflow --> db
Loading

Comments are disabled for this gist.