Modelo de Segurança
O Wippy implementa controle de acesso baseado em atributos com atores e escopos de políticas. As políticas avaliam ações e recursos usando metadados do ator e do recurso.
Esta página é uma referência de configuração e API. Exemplos completos nomeiam as entradas de registro necessárias; blocos Lua e YAML menores ilustram uma operação ou fragmento em um contexto de segurança existente.
flowchart LR
A[Actor + Scope] --> PE[Policy Evaluation] --> AD[Allow/Deny]
A -.->|Identity
Metadata| PE
PE -.->|Conditions
actor, resource, action| AD
Tipos de Entradas
| Tipo | Descrição |
|---|---|
security.policy |
Política declarativa com condições |
security.policy.expr |
Política baseada em expressão |
security.token_store |
Armazenamento e validação de tokens |
Atores
Um ator identifica o principal que executa uma ação.
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", ...}
Ator no 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 = "Sem ator no contexto" })
end
Políticas
Políticas definem regras de acesso com ações, recursos, condições e efeitos.
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
Estrutura da 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 Baseada em Expressão
Para lógica complexa, use políticas de expressão:
- 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
Condições
Condições avaliam campos do ator, da ação, do recurso e dos metadados em runtime.
Caminhos de Campo
| Caminho | Descrição |
|---|---|
actor.id |
Identificador único do ator |
actor.meta.* |
Metadados do ator (suporta aninhamento) |
action |
A ação sendo executada |
resource |
O identificador do recurso |
meta.* |
Metadados do recurso |
Operadores
| Operador | Descrição | Exemplo |
|---|---|---|
eq |
Igual | actor.meta.role eq "admin" |
ne |
Diferente | meta.status ne "deleted" |
lt |
Menor que | meta.priority lt 5 |
gt |
Maior que | actor.meta.clearance gt 2 |
lte |
Menor ou igual | meta.size lte 1000 |
gte |
Maior ou igual | actor.meta.level gte 3 |
in |
Valor em array | action in ["read", "write"] |
nin |
Valor não em array | meta.status nin ["deleted", "archived"] |
exists |
Campo existe | meta.owner exists true |
nexists |
Campo não existe | meta.deleted nexists true |
contains |
String contém | resource contains "sensitive" |
ncontains |
String não contém | resource ncontains "public" |
matches |
Correspondência regex | resource matches "^doc:.*" |
nmatches |
Não corresponde regex | actor.id nmatches "^system:.*" |
Exemplos de Condições
# 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
Escopos
Um escopo combina políticas em um contexto de segurança.
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
Escopos Nomeados (Grupos de Políticas)
Carrega as políticas atribuídas a um grupo:
-- Load scope with all policies in group
local scope, err = security.named_scope("app.security:admin")
if err then return nil, err end
Políticas são atribuídas a grupos via campo groups:
- name: admin_policy
kind: security.policy
policy:
# ...
groups:
- admin # This policy is in "admin" group
- default # Can be in multiple groups
Operações de Escopo
-- 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()
Permissões do Módulo
O modo estrito aplica verificações de permissão à criação de atores, políticas e escopos, além das operações de token:
| Ação | Recurso | Usado por | Comportamento em negação |
|---|---|---|---|
security.actor.create |
ID do ator | security.new_actor |
Gera um erro Lua |
security.policy.get |
ID da política no registro | security.policy |
Retorna nil, error |
security.policy_group.get |
ID do grupo de políticas | security.named_scope |
Retorna nil, error |
security.scope.create |
custom, with ou without |
security.new_scope, scope:with e scope:without, respectivamente |
Gera um erro Lua |
Conceda somente as operações e IDs necessários. Os exemplos de ator, escopo e token desta página pressupõem essas permissões, além das permissões específicas das operações de token.
Avaliação de Políticas
Fluxo de Avaliação
1. Sem ator ou sem escopo no contexto → o modo estrito decide (nega por padrão)
2. Verifica cada política no escopo
3. Se QUALQUER política retorna Deny → Resultado é Deny
4. Se pelo menos um Allow e nenhum Deny → Resultado é Allow
5. Nenhuma política aplicável → Resultado é Undefined
Uma verificação de acesso só passa com Allow. Undefined nega o acesso, exatamente como Deny — o modo estrito não participa uma vez que um ator e um escopo estejam ambos presentes.
Resultados de Avaliação
| Resultado | Significado |
|---|---|
allow |
Acesso concedido |
deny |
Acesso explicitamente negado |
undefined |
Nenhuma política correspondeu |
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 = "Acesso negado" })
elseif result == "undefined" then
-- Nenhuma política correspondeu - verificações de acesso tratam isso como negado
end
Verificação Rápida de Permissão
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 = "Acesso negado" })
end
Token Stores
Token stores criam, validam e revogam tokens de autenticação.
As operações Lua exigem permissões. O escopo ativo deve permitir security.token_store.get para obter o store e security.token.create, security.token.validate ou security.token.revoke para a operação correspondente. Isso vale tanto no modo estrito padrão quanto em contextos configurados explicitamente. Exemplos que criam ator ou carregam escopo nomeado também exigem security.actor.create e security.policy_group.get.
Configuração
# 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}
Opções do Token Store
| Opção | Padrão | Descrição |
|---|---|---|
store |
obrigatório | Referência do store chave-valor de apoio |
token_length |
32 | Tamanho do token em bytes (256 bits) |
default_expiration |
24h | TTL padrão do token |
token_key |
nenhum | Chave de assinatura HMAC-SHA256 (valor direto, ou ${env:NAME} para obter do registro env) |
Use token_key: ${env:NAME} em produção para evitar embutir segredos em entradas. A diretiva legada token_key_env resolve da mesma forma, mas está deprecada; prefira ${env:NAME}.
Criando 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"
Validando 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 = "Token inválido" })
end
-- Actor and scope are reconstructed from stored data
print(actor:id()) -- "user:123"
Revogando 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
Fluxo de Contexto
Ator e escopo fazem parte do contexto herdável do frame. Chamadas de função e processos gerados herdam ambos, salvo quando o chamador fornece um contexto substituto. Alterar explicitamente o ator ou escopo de um processo gerado exige process.security. Alterar o contexto de uma chamada com funcs.new():with_actor(...) ou :with_scope(...) exige funcs.security sobre o recurso security.
Definindo 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
Herança de Contexto
| Componente | Herda |
|---|---|
| Ator | Sim - passa para chamadas filhas e processos gerados |
| Escopo | Sim - passa para chamadas filhas e processos gerados |
| Modo estrito | Não - aplicação-wide |
Funções e processos criados herdam ambos o contexto de segurança do chamador. Um processo criado inicia em um frame bifurcado a partir do frame de quem o criou, que carrega o ator e o escopo de quem o criou, e o bloco security: de sua própria entrada modifica esse contexto herdado. Quando a entrada não declara bloco algum, o processo mantém inalterados o ator e o escopo de quem o criou; um criador que não tem nenhum dos dois produz um filho sem nenhum dos dois, o que o modo estrito nega. Um bloco declarado que nomeia um actor substitui o ator herdado, e seus policies e groups são mesclados ao escopo herdado; um bloco que omite actor mantém o ator de quem o criou, e um que omite tanto policies quanto groups mantém o escopo de quem o criou.
Declarando Segurança em Entradas
Um bloco de segurança tem a mesma forma em todos os lugares onde aparece:
| Campo | Tipo | Descrição |
|---|---|---|
actor.id |
string | Identidade do ator; substitui o ator herdado |
actor.meta |
map | Atributos do ator avaliados pelas políticas |
policies |
list | Registry IDs de políticas, mesclados no escopo |
groups |
list | Registry IDs de grupos de políticas, cujas políticas são mescladas no escopo |
policies e groups são registry IDs no formato namespace:name. Um nome simples não resolve — diferente do campo groups: em uma entrada de política, que assume o namespace da própria política, essas referências não têm namespace padrão.
A resolução é atômica e fail-closed. Toda política e todo grupo listado é resolvido antes de qualquer coisa ser instalada; se algum deles estiver ausente, vazio, ou não contiver políticas, toda a configuração falha e nenhum ator e nenhum escopo parcial é aplicado. Um chamador portanto nunca cruza uma fronteira carregando meio contexto.
Entradas de Processo
Entradas process.lua, process.lua.bc, function.lua e function.lua.bc aceitam um bloco security: de nível superior que se aplica a toda execução daquela 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
O bloco é aplicado quando o processo inicia, tanto em process.host quanto em terminal.host. Uma falha de resolução aborta o spawn em vez de iniciar o processo com um contexto mais fraco.
Ciclo de Vida de Serviço
Serviços supervisionados aceitam o mesmo bloco sob lifecycle, resolvido uma vez quando o controller do serviço é criado e selado pelo tempo de vida do serviço:
- 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 da CLI
Uma entrada de comando declara meta.command.security, aplicado apenas quando a entrada é lançada como comando da CLI — o operador executando wippy run <name> é a âncora de confiança para esse contexto. Isso nunca afeta um spawn comum da mesma entrada. O bloco é validado estritamente: campos desconhecidos são rejeitados, um bloco vazio é rejeitado, e security sem um name de comando é rejeitado. Veja Segurança de comandos.
Modo Estrito
O modo estrito decide o que acontece quando uma requisição não carrega ator nem escopo. Ele está ligado por padrão, então um contexto incompleto é negado. Desligá-lo é uma escolha explícita, feita no arquivo de configuração do runtime (.wippy.yaml), não no manifesto de módulo wippy.yaml:
# .wippy.yaml
security:
strict_mode: false
| Modo | Contexto Ausente | Comportamento |
|---|---|---|
| Estrito (padrão) | Sem ator/escopo | Nega |
Permissivo (strict_mode: false) |
Sem ator/escopo | Permite |
O modo estrito não muda nada uma vez que um ator e um escopo estejam presentes: a avaliação nega por padrão de qualquer forma. Ele governa apenas o caso incompleto, e é por isso que um processo que executa sem um contexto de segurança declarado falha em toda verificação sob o padrão. Dê a esse processo um bloco security:, ou inicie-o por um caminho que forneça um.
Fluxo de Autenticação
Validação de token em um 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 }
Criação de token durante 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
Fronteiras de Confiança do Runtime
A avaliação de políticas governa o que o código pode fazer. Três mecanismos separados governam qual código é admitido e para onde um contexto pode viajar.
Integridade de Módulos
Todo módulo no wippy.lock carrega um digest de artefato. No boot, um download é verificado contra o digest fixado no lock e contra o digest servido pelo hub, e packs já vendorizados são reverificados contra o lock antes de serem carregados; uma divergência é uma falha de integridade não retentável que não é contornada — o módulo não é carregado. wippy install verifica um download novo apenas contra o digest e o tamanho servidos pelo hub, apaga o arquivo e falha em caso de divergência, e então grava o digest servido de volta no lock, de modo que um digest fixado é restabelecido pelo install em vez de imposto por ele; apenas packs já presentes no diretório de vendor são conferidos contra o digest do lock. Diretórios de módulo extraídos carregam seu próprio digest e digest de árvore registrados e são verificados da mesma forma, então uma árvore vendorizada modificada é detectada em vez de considerada confiável. Veja Gerenciamento de Dependências.
Identidade Internodal do Cluster
Nós em um cluster se autenticam mutuamente. Cada nó guarda uma chave de identidade ed25519 e o mapa de chaves públicas de pares em que confia; o handshake da malha é mútuo, vinculando um HMAC sobre o segredo de gossip compartilhado a uma assinatura ed25519 sobre um transcript cobrindo ambos os IDs de nó e ambos os nonces. Um par que não está no mapa confiável, ou cuja chave anunciada por gossip discorda da entrada confiável, é rejeitado. Não existe modo não autenticado: um nó sem identidade não pode entrar na malha. Veja Identidade internodal.
Propagação para o Temporal
Um contexto de segurança que cruza para o Temporal é carregado como um header assinado, e não como entrada comum de workflow. O ator, seus metadados e os IDs de política são serializados em um envelope wippy-security e assinados com a chave HMAC do cliente, com audiência para o ID específico de workflow ou activity. O worker receptor verifica a assinatura e a audiência e resolve localmente toda política nomeada antes de o workflow ou a activity executar; qualquer falha faz a execução falhar. Um workflow executando sob um contexto de segurança também recusa signals não assinados, então um cliente Temporal externo não pode conduzi-lo. Veja Workflows e Visão geral do Temporal.
Boas Práticas
- Menor privilégio - Conceda permissões mínimas necessárias
- Negue por padrão - Use políticas de allow explícitas, habilite modo estrito
- Use grupos de políticas - Organize políticas por role/função
- Assine tokens - Sempre defina
token_keya partir de uma referência${env:NAME}em produção - Expiração curta - Use tempos de vida de token mais curtos para operações sensíveis
- Condicione no contexto - Use condições dinâmicas sobre políticas estáticas
- Audite ações sensíveis - Registre operações relevantes para segurança
Referência do Módulo Security
| Função | Descrição |
|---|---|
security.actor() |
Obtém ator atual do contexto |
security.scope() |
Obtém escopo atual do contexto |
security.can(action, resource, meta?) |
Verifica permissão |
security.new_actor(id, meta?) |
Cria novo ator |
security.new_scope(policies?) |
Cria escopo vazio ou semeado |
security.policy(id) |
Obtém política por ID |
security.named_scope(group_id) |
Obtém escopo com todas as políticas do grupo |
security.token_store(id) |
Obtém token store |