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
| Client | Store | Role |
|---|---|---|
verifyShopifySession(request).api | The session's | shopify-app-users |
verifyProxyRequest(request).api | The storefront's | storefront-shoppers, with the shopper's customerId |
verifyCustomerRequest(request).api | The storefront's | storefront-customers, with the signed-in customer's ids and automatic tenancy |
An action's api | The run's, when it has one | None: everything the store has |
api.forShop(shop) | shop | Unchanged |
api.asRole(role, { userId, customerId }) | Unchanged | role |
createClient() | Every store | None |
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.
| Grant | Means |
|---|---|
default: { read, action } | For every model the role doesn't list |
models.<model>.read | Find, 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:
| Model | Belongs to the customer by |
|---|---|
shopifyCustomer | Its id: the customer's own record |
Any model with a belongsTo shopifyCustomer | That 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.