SemiLayerDocs

Access Rules Across Joins

Each lens enforces its own rules. When you include a relation, the target lens's rule is checked — independently — against the caller's identity. Rule composition is always most-restrictive wins.

The flow

beam.recipes.query({
  where:   { cuisine: 'italian' },
  include: { reviews: true },
})
   │
   ├── grants.query on `recipes`   ← authorizes the primary read
   │
   └── grants.query on `reviews`   ← authorizes the include read
         (must also be satisfied — for the same caller)

If either rule denies, the result differs:

  • Primary denied → the whole request returns 403. No data, nothing joined.
  • Include denied → primary rows return normally; the failed relation comes back empty; meta.includeErrors gets one entry.

Most-restrictive wins

Per-row, if a rule function returns a filter (e.g. row-level security), the planner intersects it with the FK filter it was already going to apply:

reviews: {
  // RLS: users only see their own reviews OR reviews on recipes they've starred
  grants: {
    query: (claims) => ({
      $or: [
        { author_id: claims.sub },
        { recipe_id: { $in: claims.starred_recipe_ids ?? [] } },
      ],
    }),
  },
}

When the caller includes reviews, the planner sends:

WHERE recipe_id IN ($parent_ids)
  AND ($author_id = claims.sub OR recipe_id IN ($starred))

The intersection is computed once and passed to batchRead — you don't lose performance to this composition.

Fail-partial

A denied include doesn't blow up the whole response. Semantics:

const { rows, meta } = await beam.recipes.query({
  include: { reviews: true, ingredientRows: true },
})

// rows[i].reviews        → [] if access denied
// rows[i].ingredientRows → populated normally

meta.includeErrors
// [{ relation: 'reviews', reason: 'access_denied' }]

This is deliberate: if a visitor is allowed to see recipes but not reviews, they still get a working recipes page — the reviews panel is empty with a clear error surface the UI can handle.

sk_ keys bypass

Secret keys bypass access rules — they're treated as server-trusted credentials. Use them on backends, never expose them client-side. On sk_ keys:

  • All grants.* checks are skipped.
  • Every declared relation is includable.
  • No meta.includeErrors for access reasons (capability / source errors still show up).

End-user JWT (X-User-Token)

For pk_ keys, pass the end user's identity in X-User-Token (or beam.withUser(token) on the client). Rules that are functions receive those claims:

reviews: {
  grants: {
    query: (claims) => {
      if (claims?.role === 'moderator') return true         // no filter
      if (!claims?.sub) return false                         // unauthenticated
      return { author_id: claims.sub }                       // RLS filter
    },
  },
}

The same claims object is passed to every lens's rule function along the join plan — a moderator sees moderator-scoped data through every relation in one consistent query.

Debugging

  • Unexpected empty relations? Check meta.includeErrors — if reason is access_denied, the target lens's rule rejected the caller.
  • Unexpectedly populated relations? You're probably hitting the endpoint with an sk_ key and forgot to pass an X-User-Token. sk_ bypasses everything.
  • Function rules misbehaving? Log the result of the rule function against a sample claims object — the planner just applies whatever the function returns.

The audit trail

Every join check is logged internally at DEBUG level with the resolved filter (if any) and the denial reason. Platform admins can read these in the audit log for any request. See Auth & RBAC for the full rule vocabulary.

Next: Bridges Without Support.