Security Model

Wippy implements attribute-based access control with actors and policy scopes. Policies evaluate actions and resources using actor and resource metadata.

This page is a configuration and API reference. Complete examples name their required registry entries; shorter Lua and YAML fences illustrate one operation or configuration fragment in an existing security context.

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

Entry Kinds

Kind Description
security.policy Declarative policy with conditions
security.policy.expr Expression-based policy
security.token_store Token storage and validation

Actors

An actor identifies the principal performing an action.

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 in Context

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

Policies

Policies define access rules with actions, resources, conditions, and effects.

Declarative Policy

# 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

Policy Structure

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"

Expression-Based Policy

For complex logic, use expression policies:

- 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

Conditions

Conditions evaluate actor, action, resource, and metadata fields at runtime.

Field Paths

Path Description
actor.id Actor's unique identifier
actor.meta.* Actor metadata (supports nesting)
action The action being performed
resource The resource identifier
meta.* Resource metadata

Operators

Operator Description Example
eq Equals actor.meta.role eq "admin"
ne Not equals meta.status ne "deleted"
lt Less than meta.priority lt 5
gt Greater than actor.meta.clearance gt 2
lte Less than or equal meta.size lte 1000
gte Greater than or equal actor.meta.level gte 3
in Value in array action in ["read", "write"]
nin Value not in array meta.status nin ["deleted", "archived"]
exists Field exists meta.owner exists true
nexists Field not exists meta.deleted nexists true
contains String contains resource contains "sensitive"
ncontains String not contains resource ncontains "public"
matches Regex match resource matches "^doc:.*"
nmatches Regex not match actor.id nmatches "^system:.*"

Condition Examples

# 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

A scope combines policies into a security context.

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

Named Scopes (Policy Groups)

Load the policies assigned to a group:

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

Policies are assigned to groups via the groups field:

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

Scope Operations

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

Module Permissions

Strict mode applies permission checks to actor, policy, and scope construction as well as token operations:

Action Resource Used by Denial behavior
security.actor.create Actor ID security.new_actor Raises a Lua error
security.policy.get Policy registry ID security.policy Returns nil, error
security.policy_group.get Policy-group ID security.named_scope Returns nil, error
security.scope.create custom, with, or without security.new_scope, scope:with, scope:without respectively Raises a Lua error

Grant only the operations and IDs a caller needs. The actor, scope, and token examples on this page assume these permissions are present in addition to their operation-specific token permissions.

Policy Evaluation

Evaluation Flow

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

An access check passes only on Allow. Undefined denies access, exactly like Deny — strict mode plays no part once an actor and a scope are both present.

Evaluation Results

Result Meaning
allow Access granted
deny Access explicitly denied
undefined No policy matched
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
    -- No policy matched - access checks treat this as denied
end

Quick Permission Check

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

Token Stores

Token stores create, validate, and revoke authentication tokens.

The Lua operations are permission-gated. The active scope must allow security.token_store.get for acquisition and security.token.create, security.token.validate, or security.token.revoke for the corresponding operation. This applies in the default strict mode as well as in explicitly configured security contexts. Examples that create an actor or load a named scope also require security.actor.create and security.policy_group.get.

Configuration

# 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}

Token Store Options

Option Default Description
store required Backing key-value store reference
token_length 32 Token size in bytes (256 bits)
default_expiration 24h Default token TTL
token_key none HMAC-SHA256 signing key (direct value, or ${env:NAME} to pull from the env registry)

Use token_key: ${env:NAME} in production to avoid embedding secrets in entries. The legacy token_key_env directive also reads the environment registry but preserves the inline or zero value for a missing or empty lookup; a modern placeholder without a default fails when its variable is missing. The legacy directive is deprecated.

Creating 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"

Validating 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"

Revoking 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

Context Flow

Actor and scope are inheritable frame context. Function calls and spawned processes inherit both unless the caller supplies a replacement context. Explicitly changing a spawned process's actor or scope requires the process.security permission. Changing the security context of a function call through funcs.new():with_actor(...) or :with_scope(...) instead requires funcs.security on security.

Setting Context

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

Context Inheritance

Component Inherits
Actor Yes - passes to child calls and spawned processes
Scope Yes - passes to child calls and spawned processes
Strict mode No - application-wide

Functions and spawned processes both inherit the caller's security context. A spawned process starts on a frame forked from the spawner's, which carries the spawner's actor and scope, and the security: block on its own entry modifies that inherited context. When the entry declares no block, the process keeps the spawner's actor and scope unchanged; a spawner that has neither produces a child with neither, which strict mode denies. A declared block that names an actor replaces the inherited actor, and its policies and groups are merged into the inherited scope; a block that omits actor keeps the spawner's actor, and one that omits both policies and groups keeps the spawner's scope.

Declaring Security on Entries

A security block is the same shape everywhere it appears:

Field Type Description
actor.id string Actor identity; replaces the inherited actor
actor.meta map Actor attributes policies evaluate
policies list Policy registry IDs, merged into the scope
groups list Policy group registry IDs, whose policies are merged into the scope

policies and groups are registry IDs in namespace:name form. A bare name does not resolve — unlike the groups: field on a policy entry, which defaults to the policy's own namespace, these references carry no default namespace.

Resolution is atomic and fail-closed. Every listed policy and group is resolved before anything is installed; if any one of them is missing, empty, or contains no policies, the whole configuration fails and no actor and no partial scope is applied. A caller therefore never crosses a boundary holding half a context.

Process Entries

process.lua, process.lua.bc, function.lua, and function.lua.bc entries take a top-level security: block that applies to every execution of that entry:

- 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

The block is applied when the process starts, on both process.host and terminal.host. A resolution failure aborts the spawn rather than starting the process with a weaker context.

Service Lifecycle

Supervised services take the same block under lifecycle, resolved once when the service controller is created and sealed for the life of the service:

- 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

CLI Commands

A command entry declares meta.command.security, applied only when the entry is launched as a CLI command — the operator running wippy run <name> is the trust anchor for that context. It never affects an ordinary spawn of the same entry. The block is validated strictly: unknown fields are rejected, an empty block is rejected, and security without a command name is rejected. See Command security.

Strict Mode

Strict mode decides what happens when a request carries no actor and no scope. It is on by default, so an incomplete context is denied. Turning it off is an explicit choice, made in the runtime config file (.wippy.yaml), not in the module manifest wippy.yaml:

# .wippy.yaml
security:
  strict_mode: false
strict_mode Missing Context Behavior
Strict (default) No actor/scope Deny
Permissive (strict_mode: false) No actor/scope Allow

Strict mode changes nothing once an actor and a scope are present: evaluation is deny-by-default either way. It only governs the incomplete case, which is why a process that runs without a declared security context fails every check under the default. Give such a process a security: block, or start it through a path that supplies one.

Authentication Flow

Token validation in an HTTP handler:

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 }

Token creation during 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

Runtime Trust Boundaries

Policy evaluation governs what code may do. Three separate mechanisms govern what code is admitted and where a context may travel.

Module Integrity

Every module in wippy.lock carries an artifact digest. At boot, a download is verified against both the digest pinned in the lock and the digest the hub served, and already-vendored packs are re-verified against the lock before they are loaded; a mismatch is a non-retryable integrity failure that is not worked around — the module is not loaded. wippy install verifies a fresh download only against the digest and size the hub served, deletes the file and fails on mismatch, and then writes the served digest back into the lock, so a pinned digest is re-established by install rather than enforced by it; only packs already in the vendor directory are checked against the lock's digest. Extracted module directories carry their own recorded digest and tree digest and are checked the same way, so a modified vendored tree is detected rather than trusted. See Dependency Management.

Cluster Internode Identity

Nodes in a cluster authenticate each other. Each node holds an ed25519 identity key and the map of peer public keys it trusts; the mesh handshake is mutual, binding an HMAC over the shared gossip secret to an ed25519 signature over a transcript covering both node IDs and both nonces. A peer that is not in the trusted map, or whose gossip-advertised key disagrees with the trusted entry, is rejected. There is no unauthenticated mode: a node without an identity cannot join the mesh. See Internode identity.

Temporal Propagation

A security context that crosses into Temporal is carried as a signed header rather than as plain workflow input. The actor, its metadata, and the policy IDs are serialized into a wippy-security envelope and signed with the client's HMAC key, audienced to the specific workflow or activity ID. The receiving worker verifies the signature and the audience and resolves every named policy locally before the workflow or activity runs; any failure fails the execution. A workflow running under a security context also refuses unsigned signals, so an external Temporal client cannot drive it. See Workflows and Temporal overview.

Best Practices

  1. Least privilege - Grant minimum required permissions
  2. Deny by default - Use explicit allow policies, enable strict mode
  3. Use policy groups - Organize policies by role/function
  4. Sign tokens - Always set token_key from an ${env:NAME} reference in production
  5. Short expiration - Use shorter token lifetimes for sensitive operations
  6. Condition on context - Use dynamic conditions over static policies
  7. Audit sensitive actions - Log security-relevant operations

Security Module Reference

Function Description
security.actor() Get current actor from context
security.scope() Get current scope from context
security.can(action, resource, meta?) Check permission
security.new_actor(id, meta?) Create new actor
security.new_scope(policies?) Create empty or seeded scope
security.policy(id) Get policy by ID
security.named_scope(group_id) Get scope with all group policies
security.token_store(id) Get token store