Roles
The enumerable half of docs/conventions/roles-and-powers.md, generated from the two sources that own the
truth: role → scopes from the RBAC migrations (packages/db/migrations/tenant/), scope → power from
the kernel command registry. Editing this page by hand is undone by pnpm codegen and breaks CI.
What it deliberately does NOT carry is the prose — what each role is for, the three axes of authority, the
platform operator, the reasoning behind a boundary. That is hand-written, and lives in
docs/conventions/roles-and-powers.md.
Builtin roles
Section titled “Builtin roles”Seeded into every tenant and immutable (role.update/role.archive refuse them) — to customize, clone
with role.create. An operator holds one role; it materializes into the scopes of the credential minted
at login.
| Key | Name | Scopes | Description |
|---|---|---|---|
owner |
Owner | 34 | Full control, including managing operators and roles, and reading every area of the admin. There is always at least one active Owner. |
admin |
Admin | 30 | Full control of the store, including reading every area of the admin, but cannot manage operators or roles. |
catalog |
Catálogo | 18 | Manage the catalog: products, SKUs, categories, content, composition, search and media. Reads every area of the admin — logistics and stock, promotions, apps, settings and the activity trail included — a power a clone of this role can now be built without. |
marketing |
Marketing | 14 | Shape the storefront and the offer, and follow the orders that come out of it (without access to the buyer’s personal data): promotions, composition, content, search and media. Reads every area of the admin — a power a clone of this role can now be built without. |
support |
Atendimento | 12 | Serve customers: manage orders and customers (with PII access). Reads every area of the admin — catalog, logistics and stock, promotions, apps and settings — a power a clone of this role can now be built without. |
The floor: the reads no role can exclude
Section titled “The floor: the reads no role can exclude”⚠️ This section used to be the other half of the truth; it is now a footnote, and that is the change. A
read that needs no scope appears in no column of the matrix below, so for as long as the floor was 43
reads deep every role’s row understated what that role reached and no chip could ever show the difference.
The floor is now 1: whoami. The matrix is the answer, not half of it.
This is not a gap in the gate, and since IAM-READ-SCOPES it is not a gap at all. null in that file no
longer means “we found no scope for this” — it means nobody may be excluded from this read, and the
reason is on its own line. This floor used to hold 43 reads; the other 42 now carry one of seven AREA scopes
(catalog.admin.read, content.read, logistics.read, promotion.read, extensions.read,
tenant.settings.read, audit.read) and appear in the matrix below as ordinary rows.
⚠️ That was lesser privilege and tidying, NOT protection. Nothing was leaking. The rule those reads were classified under — written after a measured incident: the scope follows what the data is, not the folder it lives in — still stands and was never repealed: a carrier is still a company name and the shape of a tracking URL, and it is still not gated on a catalogue WRITE scope. What was added is a second reading — subject: a read with no personal data still belongs to an area of the business, and an area can be granted. “A stock-read-only key” was simply not expressable before; now it is.
⚠️ Before you revoke one: these areas are read from OUTSIDE their own area. The promotion editor reads
the catalogue, the order sheet reads stock_levels and routing_suggestion, and half a dozen screens read
stores. Cutting logistics.read from a role that edits promotions WILL break that editor. That is an
informed choice by whoever cuts it, not a defect to report — the seven are the granularity of REVOCATION,
not a default-deny, which is also why the migration that introduced them granted all seven to every role
that already existed. Nothing changed on anybody’s screen the day they landed; what changed is that there is
now something to take away.
And what is left on the floor holds no customer personal data. The reads that carry a shopper’s identity
— customers, orders, clusters — are behind their own scopes and never were on this floor; pii.read
governs what is legible inside THOSE, not inside this.
⚠️ The activity trail — who-did-what among the staff — has LEFT this floor. It was the one judgement call
in the kernel’s table, left open because the admin’s own navigation already hands /userlog and
/changelog to every operator, so gating it would have silently removed two screens from three roles. That
reason is now spent rather than overruled: audit and promotion_activity both carry audit.read (one
name, because they are the same question asked twice), and the migration that introduced it granted it to
every existing role — so nothing was removed from anybody. What changed is WHO DECIDES: a store that does not
want its whole staff reading who did what now revokes audit.read, instead of asking us to change a line.
Role × scope
Section titled “Role × scope”| Scope | owner |
admin |
catalog |
marketing |
support |
|---|---|---|---|---|---|
admin.users.write |
✓ | – | – | – | – |
audit.read |
✓ | ✓ | ✓ | ✓ | ✓ |
catalog.admin.read |
✓ | ✓ | ✓ | ✓ | ✓ |
catalog.category.write |
✓ | ✓ | ✓ | – | – |
catalog.product.publish |
✓ | ✓ | ✓ | – | – |
catalog.product.write |
✓ | ✓ | ✓ | – | – |
catalog.sku.write |
✓ | ✓ | ✓ | – | – |
composition.write |
✓ | ✓ | ✓ | ✓ | – |
content.read |
✓ | ✓ | ✓ | ✓ | ✓ |
content.write |
✓ | ✓ | ✓ | ✓ | – |
custom_fields.write |
✓ | ✓ | – | – | – |
customer.admin.write |
✓ | ✓ | – | – | ✓ |
extensions.egress.consent |
✓ | – | – | – | – |
extensions.read |
✓ | ✓ | ✓ | ✓ | ✓ |
extensions.write |
✓ | ✓ | – | – | – |
iam.roles.write |
✓ | – | – | – | – |
inventory.adjust |
✓ | ✓ | ✓ | – | – |
inventory.reserve |
✓ | ✓ | ✓ | – | – |
logistics.read |
✓ | ✓ | ✓ | ✓ | ✓ |
media.write |
✓ | ✓ | ✓ | ✓ | – |
notification.send |
✓ | ✓ | – | – | – |
operator.self.write |
✓ | ✓ | ✓ | ✓ | ✓ |
order.payment.write |
✓ | ✓ | – | – | – |
order.read |
✓ | – | – | ✓ | – |
order.write |
✓ | ✓ | – | – | ✓ |
pii.read |
✓ | ✓ | – | – | ✓ |
promotion.read |
✓ | ✓ | ✓ | ✓ | ✓ |
promotion.write |
✓ | ✓ | – | ✓ | – |
purchase.on_behalf |
✓ | ✓ | – | – | ✓ |
search.write |
✓ | ✓ | ✓ | ✓ | – |
shipping.write |
✓ | ✓ | – | – | – |
tenant.settings.read |
✓ | ✓ | ✓ | ✓ | ✓ |
tenant.settings.write |
✓ | ✓ | – | – | – |
tenant.store.write |
✓ | ✓ | – | – | – |
owner: two sources, compared
Section titled “owner: two sources, compared”The owner bundle has two sources, and the one that governs a live session is not the
migration. What the kernel mints at login is derived from the command registry by
deriveTenantOperatorScopes (packages/core/src/iam/owner-scopes.ts): every tenant-operator scope in the
registry, plus OPERATOR_GATE_SCOPES — so a new tenant-operator command reaches the Owner with no migration
and nobody having to remember. The row seeded in the migrations is the mirror that keeps the role table
truthful for whatever reads it (the admin’s roles screen, an operator inspecting the schema).
Both were read when this page was generated, and compared:
✅ They agree. The 34 scopes on the migration row are exactly the 34 the derivation produces, so the matrix above is safe to read for
owner.
What each scope authorizes
Section titled “What each scope authorizes”One row per scope above: the commands it lets its holder run. Scopes carried by no command are the privileged gates — the kernel consults them directly instead of guarding a command with them.
| Scope | Commands |
|---|---|
admin.users.write |
admin_user.disable, admin_user.enable, admin_user.invite, iam.api_key.create, iam.api_key.revoke, iam.api_key.rotate, operator.access_key.create, operator.access_key.revoke |
audit.read |
no command — a privileged gate, consulted by the kernel (see below) |
catalog.admin.read |
no command — a privileged gate, consulted by the kernel (see below) |
catalog.category.write |
catalog.category.create, catalog.category.move, catalog.category.set_custom_fields, catalog.category.update |
catalog.product.publish |
catalog.product.publish, catalog.product.publish_bulk, catalog.product.unpublish, catalog.product.unpublish_bulk |
catalog.product.write |
catalog.brand.archive, catalog.brand.create, catalog.brand.update, catalog.collection.archive, catalog.collection.create, catalog.collection.pin, catalog.collection.reorder, catalog.collection.unarchive, catalog.collection.unpin, catalog.collection.update, catalog.media.attach, catalog.media.detach, catalog.media.reorder, catalog.media.update, catalog.option.create, catalog.option.delete, catalog.option.update, catalog.option_value.create, catalog.option_value.update, catalog.product.archive, catalog.product.categorize, catalog.product.create, catalog.product.uncategorize, catalog.product.update |
catalog.sku.write |
catalog.sku.create, catalog.sku.delete, catalog.sku.update |
composition.write |
composition.move, composition.place, composition.placement.remove, composition.placement.reorder, composition.placement.set, composition.remove, composition.reorder, composition.toggle, composition.update_config |
content.read |
no command — a privileged gate, consulted by the kernel (see below) |
content.write |
content.page.archive, content.page.create, content.page.update |
custom_fields.write |
custom_field.archive, custom_field.define, custom_field.update |
customer.admin.write |
customer.anonymize, customer.set_custom_fields, customer_cluster.archive, customer_cluster.create, customer_cluster.pin, customer_cluster.unarchive, customer_cluster.unpin, customer_cluster.update |
extensions.egress.consent |
extension.connection.set |
extensions.read |
no command — a privileged gate, consulted by the kernel (see below) |
extensions.write |
extension.config.set, extension.install, extension.uninstall, notification.channel.register |
iam.roles.write |
admin_user.set_role, role.archive, role.create, role.update |
inventory.adjust |
inventory.adjust, inventory.set_level, warehouse.create, warehouse.update |
inventory.reserve |
inventory.commit, inventory.reclaim_expired, inventory.release, inventory.reserve |
logistics.read |
no command — a privileged gate, consulted by the kernel (see below) |
media.write |
asset.archive, asset.create, media.request_upload |
notification.send |
notification.send |
operator.self.write |
admin_user.set_locale |
order.payment.write |
order.payment.record |
order.read |
no command — a privileged gate, consulted by the kernel (see below) |
order.write |
notification.resend, order.cancel, order.cancel_request.approve, order.cancel_request.decline, order.comment.add, order.create, order.document.attach, order.hold, order.mark_delivered, order.mark_paid, order.mark_preparing, order.mark_shipped, order.refund, order.release_hold, order.shipment.create, order.shipment.mark_delivered, order.shipment.mark_picked_up, order.shipment.mark_ready_for_pickup, order.shipment.mark_shipped, order.shipment.set_tracking, order.shipment.update_tracking_status |
pii.read |
no command — a privileged gate, consulted by the kernel (see below) |
promotion.read |
no command — a privileged gate, consulted by the kernel (see below) |
promotion.write |
promotion.activate, promotion.archive, promotion.code.add, promotion.code.remove, promotion.create, promotion.pause, promotion.unarchive, promotion.update |
purchase.on_behalf |
cart.assign_customer |
search.write |
search.redirect.remove, search.redirect.set, search.synonym.remove, search.synonym.set |
shipping.write |
pickup_location.create, pickup_location.update, shipping.carrier.create, shipping.carrier.update, shipping.method.create, shipping.method.update, shipping.rate.delete, shipping.rate.set, shipping.rate.set_many, shipping.zone.create, shipping.zone.update |
tenant.settings.read |
no command — a privileged gate, consulted by the kernel (see below) |
tenant.settings.write |
notification.channel.set_enabled, notification.template.reset, notification.template.set, tenant.settings.update |
tenant.store.write |
tenant.store.create, tenant.store.set_custom_fields, tenant.store.update |
Gate scopes: audit.read, catalog.admin.read, content.read, extensions.read, logistics.read, order.read, pii.read, promotion.read, tenant.settings.read
Outside the role model
Section titled “Outside the role model”The login-driver credential (FORGE_ADMIN_SERVICE_TOKEN) is infra, not a person: it carries
operator.otp.request, operator.session.mint and never a role.
The shopper holds customer.self.read, customer.self.write — resolved from the session identity, never from a
parameter — and never appears in the table above.
The platform operator acts above the tenants, on the CONTROL face (/v1/control/commands/*, a
type: 'platform' token). These are the system: true commands; no tenant role grants them.
| Scope | Commands |
|---|---|
platform.admin_driver.mint |
platform.admin_driver.mint |
platform.extension.write |
platform.extension.config.set, platform.extension.connection.set, platform.extension.install, platform.extension.uninstall |
platform.iam.write |
platform.credential.issue, platform.credential.revoke |
platform.tenant.write |
platform.admin_host.remove, platform.admin_host.set, platform.tenant.provision |