Access control
Apps/Build/Access control

Access control

The environment token reaches every record of every store. The SDK hands your routes and actions narrower clients: one store's records, held to a role in accessControl/permissions.ib.ts. Inference Bureau enforces both.

Scoped clients

ClientStoreRole
verifyShopifySession(request).apiThe session'sshopify-app-users
verifyProxyRequest(request).apiThe storefront'sstorefront-shoppers, with the shopper's customerId
verifyCustomerRequest(request).apiThe storefront'sstorefront-customers, with the signed-in customer's ids and automatic tenancy
An action's apiThe run's, when it has oneNone: everything the store has
api.forShop(shop)shopUnchanged
api.asRole(role, { userId, customerId })Unchangedrole
createClient()Every storeNone

Use createClient() only for work that spans stores, like a schedule that visits each one.

One store's records

A model is store-owned when it has a field holding the store: myshopifyDomain on shopifyShop, the shop of a synced Shopify model, or a text field named shop on your own models. A client for one store:

  • reads, counts and searches only that store's records of store-owned models;
  • makes new records as that store's, and refuses a write that names another store;
  • answers another store's record by id as not found, so ids can't be probed;
  • runs actions and queues jobs for that store only.

Models without a store field, like an app's own settings, aren't narrowed. Give every model of per-store data a shop field.

import { createClient, type ActionRun } from "@inferencebureau/apps-sdk";

export const run: ActionRun = async ({ api }) => {
  // The run's store's reviews only (the store a webhook, install or caller named).
  const pending = await api.review.findMany({ filter: { status: { equals: "pending" } } });
  // Every store's, for work that really spans them:
  const everywhere = await createClient().review.findMany({});
};

Roles

A role-scoped client gets only what accessControl/permissions.ib.ts grants its role; a role the file doesn't name gets nothing.

GrantMeans
default: { read, action }For every model the role doesn't list
models.<model>.readFind, count, search and check passwords
models.<model>.actions.<name>Run the action: create, update and delete cover the record API's writes
globalActions.<name>A global action (true only)

Each grant is true, false or { filter }. Shopify's Admin API is open to shopify-app-users only, for its own store.

import type { AppPermissions } from "@inferencebureau/apps-sdk";

export const permissions: AppPermissions = {
  type: "ib/permissions/v1",
  roles: {
    "system-admin": { default: { read: true, action: true } },
    unauthenticated: {},
    // Staff in the Shopify admin: everything, for their own store's records.
    "shopify-app-users": { default: { read: true, action: true } },
    // Shoppers: approved reviews, and sending one.
    "storefront-shoppers": {
      models: { review: { read: { filter: { status: { equals: "approved" } } }, actions: { create: true } } },
    },
  },
};

A signed-in customer's records

The storefront-customers role (verifyCustomerRequest's client) is held to its grants like any role, and also to the customer, for every model that belongs to one:

ModelBelongs to the customer by
shopifyCustomerIts id: the customer's own record
Any model with a belongsTo shopifyCustomerThat field: the customer's record id
Synced models with the customer's Shopify id (shopifyOrder, shopifyDraftOrder, shopifyCheckout)customerId

The role reads, counts, searches and runs actions on only the customer's records of those models; a new record is made as theirs (the field is set for you), and a write naming another customer is refused. A customer without a shopifyCustomer record (the app doesn't sync customers) sees none of the models that link by record. Other models are held to the grant only, so give per-customer models a belongsTo shopifyCustomer.

Filters

A grant's filter is the same filter as findMany's, joined to the caller's own. { $session: "userId" }, "customerId", "customerRecordId" or "shop" stands for the caller's value, and a value the caller doesn't have matches nothing:

"storefront-shoppers": {
  models: { wishlistItem: { read: { filter: { customerId: { equals: { $session: "customerId" } } } }, actions: { create: true, delete: { filter: { customerId: { equals: { $session: "customerId" } } } } } } },
},

Filters are written inline. A filter in its own file (the starter's first form, accessControl/filters/…) isn't read and grants nothing; the refusal names it.

Through the API

The clients send x-ib-shop, x-ib-role and x-ib-session (JSON with userId, customerId and customerRecordId) with the environment token. An action run through a role-scoped client sees the role and session as ctx.caller. Any caller may send them to narrow itself. A refusal answers 403 policy_denied, naming the role and what it lacks. See API and keys.