Modelo de Seguridad

Wippy implementa control de acceso basado en atributos con actores y ámbitos de políticas. Las políticas evalúan acciones y recursos utilizando metadatos del actor y del recurso.

Esta página es una referencia de configuración y API. Los ejemplos completos nombran las entradas de registro necesarias; los bloques Lua y YAML más breves ilustran una operación o un fragmento de configuración dentro de un contexto de seguridad existente.

flowchart LR
    A[Actor + Scope] --> PE[Policy Evaluation] --> AD[Allow/Deny]
    A -.->|Identity
Metadata| PE PE -.->|Conditions
actor, resource, action| AD

Tipos de Entrada

Tipo Descripción
security.policy Política declarativa con condiciones
security.policy.expr Política basada en expresiones
security.token_store Almacenamiento y validación de tokens

Actores

Un actor identifica al principal que realiza una acción.

local security = require("security")

-- Create actor with metadata
local actor = security.new_actor("user:123", {
    role = "admin",
    team = "backend",
    department = "engineering",
    clearance = 3
})

-- Access actor properties
local id = actor:id()        -- "user:123"
local meta = actor:meta()    -- {role="admin", ...}

Actor en el Contexto

-- Get current actor from context
local errors = require("errors")

local actor = security.actor()
if not actor then
    return nil, errors.new({ kind = errors.PERMISSION_DENIED, message = "No actor in context" })
end

Políticas

Las políticas definen reglas de acceso con acciones, recursos, condiciones y efectos.

Política Declarativa

# src/security/_index.yaml
version: "1.0"
namespace: app.security

entries:
  # Admin full access
  - name: admin_policy
    kind: security.policy
    policy:
      actions: "*"
      resources: "*"
      effect: allow
      conditions:
        - field: actor.meta.role
          operator: eq
          value: admin
    groups:
      - admin

  # Read-only access
  - name: readonly_policy
    kind: security.policy
    policy:
      actions:
        - "*.read"
        - "*.get"
        - "*.list"
      resources: "*"
      effect: allow
    groups:
      - default

  # Resource owner access
  - name: owner_policy
    kind: security.policy
    policy:
      actions:
        - read
        - write
        - delete
      resources: "document:*"
      effect: allow
      conditions:
        - field: meta.owner
          operator: eq
          value_from: actor.id
    groups:
      - default

  # Deny confidential without clearance
  - name: deny_confidential
    kind: security.policy
    policy:
      actions: "*"
      resources: "document:*"
      effect: deny
      conditions:
        - field: meta.classification
          operator: eq
          value: confidential
        - field: actor.meta.clearance
          operator: lt
          value: 3
    groups:
      - security

Estructura de Política

policy:
  actions: "*" | "action" | ["action1", "action2"]
  resources: "*" | "resource" | ["res1", "res2"]
  effect: allow | deny
  conditions:  # Optional
    - field: "field.path"
      operator: "eq"
      value: "static_value"
      # OR
      value_from: "other.field.path"

Política Basada en Expresiones

Para lógica compleja, use políticas de expresión:

- name: flexible_access
  kind: security.policy.expr
  policy:
    actions:
      - read
      - write
    resources: "file:*"
    effect: allow
    expression: |
      (actor.meta.role == "editor" && action == "write") ||
      (action == "read" && meta.public == true) ||
      actor.id == meta.owner      
  groups:
    - editors

Condiciones

Las condiciones permiten la evaluación dinámica de políticas basada en actor, acción, recurso y metadatos.

Rutas de Campo

Ruta Descripción
actor.id Identificador único del actor
actor.meta.* Metadatos del actor (admite anidamiento)
action La acción que se está realizando
resource El identificador del recurso
meta.* Metadatos del recurso

Operadores

Operador Descripción Ejemplo
eq Igual actor.meta.role eq "admin"
ne No igual meta.status ne "deleted"
lt Menor que meta.priority lt 5
gt Mayor que actor.meta.clearance gt 2
lte Menor o igual meta.size lte 1000
gte Mayor o igual actor.meta.level gte 3
in Valor en arreglo action in ["read", "write"]
nin Valor no en arreglo meta.status nin ["deleted", "archived"]
exists El campo existe meta.owner exists true
nexists El campo no existe meta.deleted nexists true
contains String contiene resource contains "sensitive"
ncontains String no contiene resource ncontains "public"
matches Coincide con regex resource matches "^doc:.*"
nmatches No coincide con regex actor.id nmatches "^system:.*"

Ejemplos de Condiciones

# Match actor role
conditions:
  - field: actor.meta.role
    operator: eq
    value: admin

# Compare fields
conditions:
  - field: meta.owner
    operator: eq
    value_from: actor.id

# Numeric comparison
conditions:
  - field: actor.meta.clearance
    operator: gte
    value: 3

# Array membership
conditions:
  - field: actor.meta.role
    operator: in
    value:
      - admin
      - moderator

# Pattern matching
conditions:
  - field: resource
    operator: matches
    value: "^api:/v[0-9]+/admin/.*"

# Multiple conditions (AND)
conditions:
  - field: actor.meta.department
    operator: eq
    value: engineering
  - field: meta.environment
    operator: eq
    value: production

Scopes

Los scopes combinan múltiples políticas en un contexto de seguridad.

local security = require("security")

-- Get policies
local admin_policy, admin_err = security.policy("app.security:admin_policy")
if admin_err then return nil, admin_err end
local readonly_policy, readonly_err = security.policy("app.security:readonly_policy")
if readonly_err then return nil, readonly_err end

-- Create scope with policies
local scope = security.new_scope()
scope = scope:with(admin_policy)
scope = scope:with(readonly_policy)

-- Scopes are immutable - :with() returns new scope

Scopes Nombrados (Grupos de Políticas)

Cargar todas las políticas de un grupo:

-- Load scope with all policies in group
local scope, err = security.named_scope("app.security:admin")
if err then return nil, err end

Las políticas se asignan a grupos mediante el campo groups:

- name: admin_policy
  kind: security.policy
  policy:
    # ...
  groups:
    - admin      # This policy is in "admin" group
    - default    # Can be in multiple groups

Operaciones de Scope

-- Add policy
local new_scope = scope:with(policy)

-- Remove policy
local new_scope = scope:without("app.security:temp_policy")

-- Check if policy is in scope
local has = scope:contains("app.security:admin_policy")

-- Get all policies
local policies = scope:policies()

Permisos del módulo

El modo estricto aplica comprobaciones de permisos a la creación de actores, políticas y ámbitos, además de las operaciones con tokens:

Acción Recurso Utilizado por Comportamiento ante denegación
security.actor.create ID del actor security.new_actor Genera un error de Lua
security.policy.get ID de registro de la política security.policy Devuelve nil, error
security.policy_group.get ID del grupo de políticas security.named_scope Devuelve nil, error
security.scope.create custom, with o without security.new_scope, scope:with y scope:without, respectivamente Genera un error de Lua

Concede únicamente las operaciones y los ID que necesita cada llamador. Los ejemplos de actores, ámbitos y tokens de esta página presuponen que estos permisos están presentes, además de los permisos específicos de cada operación con tokens.

Evaluación de Políticas

Flujo de Evaluación

1. No actor or no scope in context → strict mode decides (deny by default)
2. Check each policy in scope
3. If ANY policy returns Deny → Result is Deny
4. If at least one Allow and no Deny → Result is Allow
5. No applicable policies → Result is Undefined

Una verificación de acceso solo pasa con Allow. Undefined deniega el acceso, exactamente igual que Deny — el modo estricto no interviene una vez que hay tanto un actor como un scope.

Resultados de Evaluación

Resultado Significado
allow Acceso concedido
deny Acceso denegado explícitamente
undefined Ninguna política coincidió
local errors = require("errors")

-- Evaluate directly
local result = scope:evaluate(actor, "read", "document:123", {
    owner = "user:456",
    classification = "internal"
})

if result == "deny" then
    return nil, errors.new({ kind = errors.PERMISSION_DENIED, message = "Access denied" })
elseif result == "undefined" then
    -- Ninguna política coincidió - las verificaciones de acceso lo tratan como denegado
end

Verificación Rápida de Permisos

local errors = require("errors")

-- Check against current context's actor and scope
local allowed = security.can("read", "document:123", {
    owner = "user:456"
})

if not allowed then
    return nil, errors.new({ kind = errors.PERMISSION_DENIED, message = "Access denied" })
end

Almacenes de Tokens

Los almacenes de tokens crean, validan y revocan tokens de autenticación.

Las operaciones de Lua están sujetas a permisos. El ámbito activo debe permitir security.token_store.get para obtener el almacén y security.token.create, security.token.validate o security.token.revoke para la operación correspondiente. Esto se aplica tanto en el modo estricto predeterminado como en contextos de seguridad configurados explícitamente. Los ejemplos que crean un actor o cargan un ámbito con nombre también requieren security.actor.create y security.policy_group.get.

Configuración

# src/auth/_index.yaml
version: "1.0"
namespace: app.auth

entries:
  # Register environment variable
  - name: os_env
    kind: env.storage.os

  - name: AUTH_SECRET_KEY
    kind: env.variable
    variable: AUTH_SECRET_KEY
    storage: app.auth:os_env

  # Backing store for tokens
  - name: token_data
    kind: store.memory
    lifecycle:
      auto_start: true

  # Token store
  - name: tokens
    kind: security.token_store
    store: app.auth:token_data
    token_length: 32
    default_expiration: "24h"
    token_key: ${env:AUTH_SECRET_KEY}

Opciones del Almacén de Tokens

Opción Predeterminado Descripción
store requerido Referencia al almacén clave-valor de respaldo
token_length 32 Tamaño del token en bytes (256 bits)
default_expiration 24h TTL predeterminado del token
token_key ninguno Clave de firma HMAC-SHA256 (valor directo, o ${env:NAME} para obtenerla del registro de entorno)

Use token_key: ${env:NAME} en producción para evitar incrustar secretos en las entradas. La directiva heredada token_key_env se resuelve de la misma forma pero está obsoleta; prefiera ${env:NAME}.

Creación de Tokens

local security = require("security")

-- Get token store
local store, err = security.token_store("app.auth:tokens")
if err then
    return nil, err
end

-- Create actor and scope
local actor = security.new_actor("user:123", {
    role = "user",
    email = "user@example.com"
})

local scope, scope_err = security.named_scope("app.security:default")
if scope_err then
    store:close()
    return nil, scope_err
end

-- Create token
local token, create_err = store:create(actor, scope, {
    expiration = "7d",  -- Override default expiration
    meta = {
        device = "mobile",
        ip = "192.168.1.1"
    }
})
store:close()
if create_err then return nil, create_err end
return token

-- Token format: base64_token.hmac_signature (if token_key set)
-- Example: "dGVzdHRva2VuMTIz.a1b2c3d4e5f6"

Validación de Tokens

local errors = require("errors")

-- Validate token
local actor, scope, err = store:validate(token)
store:close()
if err then
    return nil, errors.new({ kind = errors.PERMISSION_DENIED, message = "Invalid token" })
end

-- Actor and scope are reconstructed from stored data
print(actor:id())  -- "user:123"

Revocación de Tokens

-- Revoke single token
local ok, err = store:revoke(token)
if err then
    store:close()
    return nil, err
end

-- Close store when done
store:close()
return ok

Flujo de Contexto

El actor y el ámbito forman parte del contexto heredable del marco. Las llamadas a funciones y los procesos generados heredan ambos salvo que el llamador proporcione un contexto de reemplazo. Cambiar explícitamente el actor o el ámbito de un proceso generado requiere el permiso process.security. Cambiar el contexto de seguridad de una llamada de función mediante funcs.new():with_actor(...) o :with_scope(...) requiere en cambio funcs.security sobre security.

Establecer el Contexto

local funcs = require("funcs")

-- Call function with security context
local caller, err = funcs.new():with_actor(actor)
if err then return nil, err end
caller, err = caller:with_scope(scope)
if err then return nil, err end
local result, call_err = caller:call("app.api:protected_endpoint", data)
if call_err then return nil, call_err end

Herencia del Contexto

Componente Hereda
Actor Sí - se pasa a llamadas hijas y procesos generados
Scope Sí - se pasa a llamadas hijas y procesos generados
Modo estricto No - es a nivel de aplicación

Tanto las funciones como los procesos generados heredan el contexto de seguridad del llamador. Un proceso generado arranca sobre un frame bifurcado del de quien lo genera, que lleva el actor y el scope de este, y el bloque security: de su propia entrada modifica ese contexto heredado. Cuando la entrada no declara ningún bloque, el proceso conserva sin cambios el actor y el scope de quien lo genera; un generador que no tiene ninguno de los dos produce un hijo sin ninguno de los dos, lo que el modo estricto deniega. Un bloque declarado que nombra un actor reemplaza al actor heredado, y sus policies y groups se fusionan en el scope heredado; un bloque que omite actor conserva el actor de quien lo genera, y uno que omite tanto policies como groups conserva su scope.

Declarar Seguridad en las Entradas

Un bloque de seguridad tiene la misma forma en todos los lugares donde aparece:

Campo Tipo Descripción
actor.id string Identidad del actor; reemplaza al actor heredado
actor.meta map Atributos del actor que evalúan las políticas
policies list Registry IDs de políticas, fusionados en el scope
groups list Registry IDs de grupos de políticas, cuyas políticas se fusionan en el scope

policies y groups son registry IDs en forma namespace:name. Un nombre suelto no se resuelve — a diferencia del campo groups: de una entrada de política, que usa por defecto el namespace de la propia política, estas referencias no llevan namespace por defecto.

La resolución es atómica y fail-closed. Cada política y grupo listado se resuelve antes de instalar nada; si alguno falta, está vacío o no contiene políticas, toda la configuración falla y no se aplica ningún actor ni ningún scope parcial. Por lo tanto, un llamador nunca cruza una frontera con medio contexto.

Entradas de Proceso

Las entradas process.lua, process.lua.bc, function.lua y function.lua.bc aceptan un bloque security: de nivel superior que se aplica a cada ejecución de esa entrada:

- name: worker_process
  kind: process.lua
  source: file://worker.lua
  method: main
  security:
    actor:
      id: "service:worker"
      meta:
        role: worker
        service: true
    policies:
      - app.security:worker_policy
    groups:
      - app.security:workers

El bloque se aplica cuando el proceso arranca, tanto en process.host como en terminal.host. Un fallo de resolución aborta el spawn en lugar de arrancar el proceso con un contexto más débil.

Ciclo de Vida del Servicio

Los servicios supervisados aceptan el mismo bloque bajo lifecycle, resuelto una vez al crear el controlador del servicio y sellado durante toda la vida del servicio:

- name: worker
  kind: process.service
  process: app:worker_process
  host: app:processes
  lifecycle:
    auto_start: true
    security:
      actor:
        id: "service:worker"
      groups:
        - app.security:workers

Comandos de la CLI

Una entrada de comando declara meta.command.security, aplicado solo cuando la entrada se lanza como comando de la CLI — el operador que ejecuta wippy run <name> es el ancla de confianza de ese contexto. Nunca afecta a un spawn ordinario de la misma entrada. El bloque se valida estrictamente: los campos desconocidos se rechazan, un bloque vacío se rechaza, y security sin un name de comando se rechaza. Ver Seguridad de comandos.

Modo Estricto

El modo estricto decide qué ocurre cuando una solicitud no lleva actor ni scope. Está activado por defecto, así que un contexto incompleto se deniega. Desactivarlo es una decisión explícita, tomada en el archivo de configuración del runtime (.wippy.yaml), no en el manifiesto de módulo wippy.yaml:

# .wippy.yaml
security:
  strict_mode: false
Modo Contexto Ausente Comportamiento
Estricto (por defecto) Sin actor/scope Deniega
Permisivo (strict_mode: false) Sin actor/scope Permite

El modo estricto no cambia nada una vez que hay un actor y un scope: la evaluación deniega por defecto en cualquier caso. Solo rige el caso incompleto, y por eso un proceso que se ejecuta sin un contexto de seguridad declarado falla todas las verificaciones bajo el valor por defecto. Dele a ese proceso un bloque security:, o arránquelo por una ruta que le suministre uno.

Flujo de Autenticación

Validación de token en un handler HTTP:

local http = require("http")
local security = require("security")

local function protected_handler()
    local req, req_err = http.request()
    if req_err then return nil, req_err end
    local res, res_err = http.response()
    if res_err then return nil, res_err end

    local function respond(status, body)
        local content_type_err = res:set_header("Content-Type", "application/json")
        if content_type_err then return nil, content_type_err end
        local status_err = res:set_status(status)
        if status_err then return nil, status_err end
        local write_err = res:write_json(body)
        if write_err then return nil, write_err end
        return true
    end

    -- Extract and validate token
    local auth, header_err = req:header("Authorization")
    if header_err then return nil, header_err end
    if not auth then
        return respond(http.STATUS.UNAUTHORIZED, {error = "Missing authorization"})
    end

    local token = auth:match("^Bearer%s+(.+)$")
    if not token then
        return respond(http.STATUS.UNAUTHORIZED, {error = "Expected a bearer token"})
    end
    local store, store_err = security.token_store("app.auth:tokens")
    if store_err then
        return respond(http.STATUS.INTERNAL_ERROR, {error = "Token store unavailable"})
    end

    local actor, scope, validate_err = store:validate(token)
    store:close()
    if validate_err then
        return respond(http.STATUS.UNAUTHORIZED, {error = "Invalid token"})
    end

    -- Evaluate the actor and scope reconstructed from this token.
    if scope:evaluate(actor, "api.users.read", "users") ~= "allow" then
        return respond(http.STATUS.FORBIDDEN, {error = "Forbidden"})
    end

    return respond(http.STATUS.OK, {user = actor:id()})
end

return { handler = protected_handler }

Creación de token durante el login:

local actor = security.new_actor("user:" .. user.id, {role = user.role})
local scope, scope_err = security.named_scope("app.security:" .. user.role)
if scope_err then return nil, scope_err end

local store, store_err = security.token_store("app.auth:tokens")
if store_err then return nil, store_err end
local token, token_err = store:create(actor, scope, {expiration = "24h"})
store:close()
if token_err then return nil, token_err end
return token

Fronteras de Confianza del Runtime

La evaluación de políticas rige lo que el código puede hacer. Tres mecanismos separados rigen qué código se admite y hasta dónde puede viajar un contexto.

Integridad de Módulos

Cada módulo de wippy.lock lleva un digest de artefacto. En el arranque, una descarga se verifica contra el digest fijado en el bloqueo y contra el digest que sirvió el hub, y los packs ya vendorizados se reverifican contra el bloqueo antes de cargarse; una discrepancia es un fallo de integridad no reintentable que no se rodea — el módulo no se carga. wippy install verifica una descarga nueva solo contra el digest y el tamaño que sirvió el hub, elimina el archivo y falla ante una discrepancia, y luego escribe el digest servido de vuelta en el bloqueo, de modo que un digest fijado se restablece con install en lugar de exigirse; solo los packs que ya están en el directorio de vendor se comprueban contra el digest del bloqueo. Los directorios de módulo extraídos llevan su propio digest registrado y digest de árbol y se comprueban de la misma forma, de modo que un árbol vendorizado modificado se detecta en lugar de confiarse en él. Ver Gestión de Dependencias.

Identidad Internodo del Clúster

Los nodos de un clúster se autentican entre sí. Cada nodo posee una clave de identidad ed25519 y el mapa de claves públicas de pares en las que confía; el handshake de la malla es mutuo y vincula un HMAC sobre el secreto de gossip compartido a una firma ed25519 sobre una transcripción que cubre ambos IDs de nodo y ambos nonces. Un par que no está en el mapa de confianza, o cuya clave anunciada por gossip discrepa de la entrada de confianza, se rechaza. No existe un modo sin autenticar: un nodo sin identidad no puede unirse a la malla. Ver Identidad internodo.

Propagación en Temporal

Un contexto de seguridad que cruza hacia Temporal se transporta como un header firmado, no como entrada plana del workflow. El actor, sus metadatos y los IDs de políticas se serializan en un sobre wippy-security y se firman con la clave HMAC del cliente, con audiencia en el ID concreto del workflow o de la activity. El worker receptor verifica la firma y la audiencia y resuelve localmente cada política nombrada antes de que el workflow o la activity se ejecute; cualquier fallo hace fallar la ejecución. Un workflow que se ejecuta bajo un contexto de seguridad también rechaza señales sin firmar, de modo que un cliente externo de Temporal no puede operarlo. Ver Workflows y Resumen de Temporal.

Mejores Prácticas

  1. Privilegio mínimo - Otorgue los permisos mínimos requeridos
  2. Denegar por defecto - Use políticas de permiso explícitas, active el modo estricto
  3. Use grupos de políticas - Organice las políticas por rol/función
  4. Firme los tokens - Configura siempre token_key a partir de una referencia ${env:NAME} en producción
  5. Expiración corta - Use tiempos de vida cortos para operaciones sensibles
  6. Condicione sobre el contexto - Prefiera condiciones dinámicas frente a políticas estáticas
  7. Audite acciones sensibles - Registre operaciones relevantes para la seguridad

Referencia del Módulo security

Función Descripción
security.actor() Obtiene el actor actual desde el contexto
security.scope() Obtiene el scope actual desde el contexto
security.can(action, resource, meta?) Verifica permiso
security.new_actor(id, meta?) Crea un nuevo actor
security.new_scope(policies?) Crea un scope vacío o con políticas iniciales
security.policy(id) Obtiene política por ID
security.named_scope(group_id) Obtiene scope con todas las políticas del grupo
security.token_store(id) Obtiene un almacén de tokens