Skip to content

Permissions ​

Metacore has two parallel permission systems that work together: capabilities (what an addon may do) and permissions (what a user may do). Every call has to satisfy both. This page explains the model and the enforcement points.

Two layers, one decision ​

       ┌─────────────────────────────────────┐
       │          incoming request            │
       └────────────────────┬────────────────┘
                            ▼
            ┌───────────────────────────────┐
            │  1. capability check           │
            │     (does the addon have      │
            │      permission to do this?)  │
            │     enforced by:              │
            │     security.Enforcer         │
            └────────────┬──────────────────┘
                         ▼ allowed
            ┌───────────────────────────────┐
            │  2. permission check           │
            │     (is this user allowed     │
            │      to take this action?)    │
            │     enforced by:              │
            │     permission.Service         │
            └────────────┬──────────────────┘
                         ▼ allowed
                  handler runs

Both checks run on every CRUD call, every action call, every WebSocket subscribe. A request is allowed only if both clear.

Capabilities — the addon contract ​

A capability is something an addon promises to do, declared in manifest.capabilities[]:

json
"capabilities": [
  { "kind": "db:read",    "target": "addon_tickets.*" },
  { "kind": "db:write",   "target": "addon_tickets.*" },
  { "kind": "event:emit", "target": "tickets.changed" },
  { "kind": "http:fetch", "target": "https://api.example.com/*", "reason": "external sync" }
]

The closed set of capability kinds is:

KindTargetsWhat it covers
db:readschema/table globReading a table
db:writeschema/table globInserts, updates, deletes
event:emitevent namePublishing on the in-process event bus / WebSocket hub
event:subscribeevent nameSubscribing to a published event
http:fetchURL prefixOutbound HTTP from WASM-sandboxed code
secrets:readsecret globReading a host-managed secret
fs:readpath globReading bundled read-only files
cron:registercron expressionRegistering a scheduled sweep
queue:produce / queue:consumequeue nameProducing / consuming on a queue
file-storage:writepath globWriting to file storage (exports, attachments)
time:wallclock—Reading the host wall clock

The addon's own schema (addon_<key>.*) is always accessible — never declare it. The full list and target syntax is in the kernel WASM ABI.

Why capabilities exist ​

Without them, an addon could do anything the host process can — read every table, call every external API, exfiltrate every secret. Capabilities make the addon's surface explicit and reviewable. An operator looking at a manifest sees exactly what the addon can touch, and the kernel enforces it at every call.

Enforcement modes ​

The kernel runs the security enforcer in one of two modes, configurable per host:

  • Shadow. Capability violations are logged as warnings but the call proceeds. Useful in development to discover missing declarations without breaking flows.
  • Enforce. Capability violations 403. Used in production.

The mode is part of the host's config; the addon doesn't know which one is active.

What targets look like ​

Target syntax depends on the kind: http:fetch matches a URL prefix, cron:register a cron expression, event:* an event name, and db:read/db:write a schema.table glob (e.g. addon_tickets.*). The addon's own schema is implicit; cross-schema access needs an explicit grant (db:read public.users) and the installer rejects manifests that overreach.

Permissions and roles — the user contract ​

v3 declares the user-facing side under rbac: first-class roles plus the permissions they bundle.

json
"rbac": {
  "permissions": [
    { "key": "tickets.read",   "label": "View tickets" },
    { "key": "tickets.write",  "label": "Create / edit / delete tickets" },
    { "key": "tickets.export", "label": "Export tickets" }
  ],
  "roles": [
    { "key": "tickets_agent",   "label": "tickets.role.agent",
      "permissions": ["tickets.read", "tickets.write"] },
    { "key": "tickets_viewer",  "label": "tickets.role.viewer",
      "permissions": ["tickets.read"] }
  ]
}

Permission keys are opaque to the kernel — they're addon-defined strings. The kernel stores grants, resolves a user's effective set, and checks them. (Kernel 3.x dual-read maps a legacy v2 permissions[] array into rbac.permissions[]; roles are new in v3.)

How users get permissions ​

That's the host's responsibility. A typical host has:

  • Roles — named bundles of permissions (viewer, operator, admin).
  • Per-user grants — direct grants outside any role.
  • Per-org defaults — what every user in an org gets by default.

The kernel exposes an API for managing these (/api/permissions/...); how the host's admin UI looks is up to the host.

How permissions map to CRUD ​

The runtime maps permission IDs to CRUD operations via convention or explicit declaration in the manifest:

OperationDefault permissionOverride
GET /api/dynamic/:model:model.readmanifest
GET /api/dynamic/:model/:id:model.readmanifest
POST /api/dynamic/:model:model.writemanifest
PUT /api/dynamic/:model/:id:model.writemanifest
DELETE /api/dynamic/:model/:id:model.writemanifest

For actions, the manifest declares the required permission explicitly:

json
{ "key": "close_with_reason", "permission": "tickets.write", "...": "..." }

UI gating ​

The SDK reads the user's effective permissions and gates components automatically:

tsx
import { useCapabilities, CapabilityGate } from '@asteby/metacore-runtime-react'

// Hook form — returns { has, all, any }.
const can = useCapabilities()
if (!can.has('tickets.write')) return <ReadOnlyView />

// Declarative form.
<CapabilityGate require="tickets.write" fallback={<ReadOnlyView />}>
  <CreateButton />
</CapabilityGate>

Built-in components — <DynamicTable>, <DynamicForm>, action buttons — already check the right permissions and hide / disable themselves. You only need explicit checks for custom UI.

Tenancy ​

Both layers operate inside the user's tenant scope. The kernel auto-filters every dynamic CRUD query by org_id; cross-tenant data is not reachable from a normal request, even with the right permission. Cross-tenant access requires an explicit superuser capability the kernel ships with disabled.

Audit ​

Every capability check and permission check produces an audit event (allowed or denied), routed through the kernel's audit hook. Hosts plug in their own sink — typically a structured log or a dedicated audit table.

Metacore is open-source. Apache-2.0.