Permission Checks

Permission checks answer one question:

Can this principal perform this action on this resource in this scope?

The main endpoint is:

POST /permissions/check

Single check

{
  "userId": "user-alice",
  "action": "read",
  "resource": "device/dev-1",
  "product": "acme-iot",
  "service": "device-service",
  "scope": "tenant-acme-prod"
}

Every check names its product and service (in the body, or through an XRN resource that carries them). An agent or service account is checked with principalType + principalId instead of userId.

The response uses JSON:API shape:

{
  "data": {
    "type": "permission-check",
    "id": "user-alice:read:device",
    "attributes": {
      "allowed": true,
      "effect": "can",
      "reason": null,
      "sources": []
    }
  }
}

Check permissions on the server side for API writes and sensitive reads. Frontend checks are useful for hiding controls, but they should not be the only enforcement layer.

Checks with facts

Conditions (when) evaluate only against facts with a known source:

  • the engine: the resource type, id and scope, the principal, and the server clock (time.hour, time.dayOfWeek, UTC);
  • server-derived request facts: request.mfa / request.mfaAge from the caller's verified token and request.ip from the deployment's client-IP source — on /me/permissions/check;
  • resource attributes (resource.createdBy, tags, status…): declared in your catalog manifest and attested by your backend — POST /broker/permissions/check (on behalf of a signed-in user) or POST /attested/permissions/check (background jobs), or checkWithAttestedFacts in @saaslayer/permissions/server direct mode;
  • trusted context (context.<name>): only for products that opt in, only from service credentials.

A check can never set these itself: an attestation on these routes is 403 AttestationNotAllowed, a request.* key is 400 RequestFactNotSettable, and once attribute conditions are enabled a context field is 400 ContextNotAllowed.

When a matching rule needs a fact the check doesn't have, the decision is a deny that says so:

{ "allowed": false, "reason": "attestation_required", "unresolved": ["resource.createdBy"] }

(context_required and request_fact_unavailable work the same way.) Browsers can't attest, so a frontend <Can> on a conditional permission is always false — return per-item actions from your backend (permittedActionsMany) instead. See docs/platform/CONTEXT-KEYS.md for the full key list.

Explain mode

Set explain: true to include condition results and near misses where available.

{
  "userId": "user-alice",
  "action": "delete",
  "resource": "invoice/inv-123",
  "scope": "tenant-acme-prod",
  "explain": true
}

Use explain mode for admin UI, debugging, tests, and support workflows. Avoid returning detailed authorization internals to untrusted end users unless your product explicitly needs that transparency.

Batch checks

Use batch checks when one request needs several decisions.

POST /permissions/check-batch
{
  "userId": "user-alice",
  "scope": "tenant-acme-prod",
  "checks": [
    { "action": "read", "resource": "device/dev-1", "checkId": "read-1" },
    { "action": "write", "resource": "device/dev-1" },
    { "action": "delete", "resource": "device/dev-2", "scope": "tenant-acme-dev" }
  ]
}

Batch requests accept up to 25 checks. The response has one entry in decisions per check, in request order:

{
  "allAllowed": false,
  "decisions": [
    { "index": 0, "checkId": "read-1", "action": "read", "resource": "device/dev-1", "allowed": true, "effect": "can" },
    { "index": 1, "action": "write", "resource": "device/dev-1", "allowed": false, "effect": "deny", "reason": "default deny" },
    { "index": 2, "action": "delete", "resource": "device/dev-2", "allowed": false, "effect": "deny" }
  ]
}

Read decisions by position (or by your checkId). Each check is decided exactly like a single check: its own scope (scope on the check, else the shared scope), its own context, and its own resource id. A check whose XRN names a different org than its scope fails alone with error: { code: "ScopeMismatch" }; the other checks are still decided. /permissions/bulk-check and the multi form of /me/permissions/check return the same decisions shape.

List views

For list views, prefer one of these patterns:

  • Ask your application database for candidate records, then batch-check the actions that control visible buttons.
  • Pre-filter by tenant or group membership in your application query, then use RBAC for sensitive actions.
  • Use direct grants for explicit sharing workflows, then check the specific resource before showing or opening it.

Avoid running one network request per row. If a page renders 50 rows with three actions each, design a batch or backend aggregation path.

Resource naming

A resource is a bare type, a type/id, or an XRN xrn:<product>:<service>:<region>:<scope>:<type>/<id>:

invoice
invoice/inv-123
xrn:acme:billing:us-west-1:tenant-acme-prod:invoice/inv-123
  • A type-level check (no id — "create invoice", "list invoices") never matches an instance pattern (invoice/123, invoice/prod-*), for allows and denies. Send the id for instance actions.
  • Build XRNs with @saaslayer/permissions/server buildXrn: scope and id segments are percent-encoded (site%3Aorg%3As1).
  • A legacy * in an XRN's region or scope means unset (filled from the deployment and the request scope); * is never a wildcard in a request.
  • The decision's scope is the XRN's scope, else the request scope. If both are set and belong to different orgs the check is a 400 ScopeMismatch.

Request errors

Malformed requests are 400 with errors[0].details.code one of: ProductRequired, ServiceRequired, ServiceMismatch (body service differs from the XRN's), InvalidResourceLocator, WildcardNotAllowed, InvalidAction, ScopeMismatch, and UnknownResourceType (the product/service/type is not in the published catalog, when registry enforcement is on). A permissions doc the reader cannot use answers allowed: false with reason stale_permissions (retryable) or doc_corrupt, never a 5xx.

Deny behavior

Evaluation uses deny-overrides-allow:

  1. Merge global permissions and matching scoped permissions.
  2. Check denies.
  3. Apply boundaries.
  4. Check allows.
  5. Fall back to grants where configured.
  6. Default deny.

If the result is unexpected, inspect the user's permission state:

GET /users/user-alice/perm-state