Skip to main content

API Permissions

API permissions decide which roles may read and change data through the API. The server checks them on GraphQL, REST, MCP and agent-tool calls. They are separate from UI permissions, which decide what a screen renders: a role that can see a button still needs the API permission for the call behind it.

The check runs where the environment turns it on (Turning It On).

YAML Structure​

A module grants API permissions in its permissions: block, with the same keys as UI permissions:

permissions:
- name: API/Contact/Store/Create
displayName:
en-US: "API: Create Stores"
description:
en-US: Create stores through the API
roles:
- Administrator
- Store Manager
AttributeDescription
nameThe API name, in the format below. cxtms does not check names, so a misspelling passes validation.
rolesThe roles that receive the name. With no roles, nobody holds it.
displayName, descriptionLocalized text for people reading the permission.

Names​

API/<Entity>[/<Subtype>]/<Action>, compared case-insensitively.

PartMeaning
EntityThe singular entity name, without the operation's verb. createContact and importContacts both use Contact.
SubtypeThe record's type, on writes to the entities in the next table. Reads have no subtype.
ActionCreate, Update, Delete, Read, List, Get or Execute.
EntitySubtypeExample
ContactContactTypeAPI/Contact/PoolPoint/Delete
OrderOrderTypeAPI/Order/DeliveryOrder/Update
AttachmentAttachmentTypeAPI/Attachment/Picture/Create
ChargeChargeTypeAPI/Charge/Expense/Create
AccountingTransactionAccountingTransactionTypeAPI/AccountingTransaction/Invoice/Update
RateRateTypeAPI/Rate/ClientRate/Create
DispatchRoute, DispatchRouteTemplateRouteTypeAPI/DispatchRoute/Pickup/Create
CalendarCalendarType, free text, once a module declares one for the actionAPI/Calendar/Create while none is declared

Other entities have no subtype: API/Commodity/Update.

Declaring a Name Grants It​

Every name a request needs is enforced whether or not a module declares it. A user passes when one of their roles holds the name or a wildcard that covers it. A name no role holds refuses everyone except system administrators.

A declaration gives the name to the roles it lists. It does not switch a check on:

  • Each name is granted on its own. API/Contact/Store/Create does not grant Store Update or Delete.
  • A misspelled declaration grants nothing. API/Contact/Stroe/Create is a valid string, so nothing reports the mistake. The role is refused when it makes the call.
  • Wildcards grant every name they cover. For API/Contact/Store/Create the server looks for that name, API/Contact/Store/*, API/Contact/* and API/*. API/Contact/* stops at that entity: it does not cover API/ContactAddress/List.
  • Wildcards exist only under API/. A System/... wildcard grants nothing, and API/* grants no UI name.
  • Declaring a subtype raises the bar for requests of unknown type. Once API/Contact/Customer/Create is declared, an import needs it too (see the next section). Wildcards do not count as declaring a subtype.
  • A soft-deleted module's permissions grant nothing.

Organization administrators keep full access through the API/* wildcard that the core app declares for the Administrator role. There is no code bypass for that role, so every other role needs the API names declared for it.

Which Name a Request Needs​

RequestName it needs
Create with a typeThe subtype name only: API/Contact/Store/Create. API/Contact/Create does not satisfy it.
Update or delete by idThe stored type: deleteContact on a Pool Point needs API/Contact/PoolPoint/Delete.
Delete, soft delete or restore (an update that sets isDeleted)Delete.
Change the type from A to BUpdate and Delete on A, Create and Update on B.
Create with a missing or unrecognized typeCreate on every declared subtype.
Free-text type no module declares for the actionThe action on every declared subtype, so a user cannot step around the declarations by inventing a type.
ImportCreate on every subtype declared for Create, and Update on every subtype declared for Update, because each row may carry its own type and rows match existing records of any type. With no subtype declared, API/Contact/Create and API/Contact/Update.
Child record, such as deleteContactAddress, commodity tags or order documentsUpdate on the parent, with the parent's subtype: an address on a Store needs API/Contact/Store/Update. Moving a child to a new parent checks both parents.
Entity with no subtypeThe entity-level name: API/Commodity/Update.

So a role that holds API/Contact/Store/Create and API/Contact/Store/Update is still refused on importContacts in an organization that also declares API/Contact/PoolPoint/Create.

Reads​

ReadName it needs
Any GraphQL field, root or nested, that returns Order, Commodity, InventoryItem or AttachmentAPI/<Entity>/Read. Selecting attachments on a commodity needs API/Attachment/Read as well as API/Commodity/Read.
A root query of most other entities, returning many recordsAPI/<Entity>/List: API/Contact/List.
A root query of most other entities, returning one recordAPI/<Entity>/Get: API/OrderStatus/Get.
  • Nested fields of List and Get entities are not checked, so an order still shows its status to a role without API/OrderStatus/Get.
  • Mutation payloads are not read-checked.
  • API/<Entity>/* grants List and Get together.
  • A refused field resolves to null with an AUTH_NOT_AUTHORIZED error. A refused non-null field nulls its parent, as GraphQL requires.
  • REST read endpoints that send a gated query are checked the same way and answer 403.
  • Reads of a user's own records stay open: currentUser, the user's own organizationUser, and the organization and organizations fields used by the profile and organization switcher. So do appRoutes, appComponents and appComponent, which the app renders from.

Workflows​

Tasks run trusted. Only the outermost request is checked. A workflow's tasks, Flow transitions, after-save handlers and the commands a handler sends are not checked against the caller, so a Query/GraphQL step or Contact/Create@1 is never refused for the user who started the workflow.

What gates a workflow is who may start it:

WorkflowA user's direct run needs
Declares permissionThat permission. It is checked whether or not API permissions are enabled.
Declares no permissionAPI/Workflow/Execute.
runAs: SYSTEM with no permissionRefused for every user, because it would hand them system privileges. Saving one that users can start fails validation with STRUCT-011.

A direct run is one where a user picks the workflow: GraphQL executeWorkflow, MCP tools, agent tools and the workflow trigger endpoint. API/Workflow/Execute does not apply to entity triggers, child workflows, Public API calls, webhooks and background jobs.

UI and API Names​

UI namesAPI names
FormatFree-form: System/Contacts/Update, System/Contact/Store/CreateAPI/<Entity>[/<Subtype>]/<Action>, always singular
Checked byThe web and mobile clients, on components, routes and hasPermissionThe server, on API calls
MatchingExact name, case-sensitiveCase-insensitive, and wildcards grant

A UI name does not grant an API name, so a role needs both to use a button and the mutation behind it. Declare both with the same roles:

permissions:
- name: System/Contact/Store/Create
displayName:
en-US: Create Stores
roles:
- Administrator
- Store Manager
- name: API/Contact/Store/Create
displayName:
en-US: "API: Create Stores"
roles:
- Administrator
- Store Manager

currentUser.permissions returns every declared name the user's grants cover, including API names covered only by a wildcard, and leaves out permissions of soft-deleted modules.

A few server-side checks use System/ names by their exact name, such as System/UserGroups/Update and System/TimeTracking/Manage. API/* does not pass them.

Record Ownership​

The record a request addresses must belong to an organization the caller can access, and must match the request's organization when it names one. Otherwise the request fails with Not authorized for this record., which does not name the record's organization. This check runs whether or not API permissions are enabled.

What Is Not Checked​

  • System administrators: members of the System Administration organization. An organization's Administrator role is checked like any other, and passes through API/*.
  • Requests with no user, such as background jobs.
  • Anything running inside a checked request or a workflow (see Workflows).

Coverage​

The table lists what the server checks today. An operation it does not list is not gated by an API permission yet. A write is checked on the command it sends, so GraphQL, MCP and agent tools behave the same, and so does a REST endpoint that sends a listed command.

List and Get apply to root queries; Read applies to every field that returns the entity.

AreaWrites (Create, Update, Delete)Reads
ContactsContact by ContactType. Addresses and payment methods count as Update on the contact.Contact, ContactAddress, ContactPaymentMethod: List, Get
OrdersOrder by OrderType. OrderCommodity, OrderDocument and OrderEntity count as Update on the order.Order: Read. OrderCommodity, OrderDocument, OrderEntity: List, Get
Commodities and inventoryCommodity, InventoryItem. Commodity tags count as Update on the commodity.Commodity, InventoryItem: Read
TrackingTrackingEvent, CommodityTrackingNumberTrackingEvent, CommodityTrackingNumber: List, Get
AttachmentsAttachment by AttachmentTypeAttachment: Read
AccountingCharge, AccountingTransaction and Rate by type. Payment, AccountingItem, AccountingAccount, Discount, SalesTax, PaymentTerm. Linking charges counts as Update on the transaction.The same entities: List, Get
Moves and routesJob, OrderMove, Route, RouteLeg. DispatchRoute and DispatchRouteTemplate by RouteType. Legs, stops and route links count as Update on the parent, and assignJob as Update on the job.The same entities, plus OrderMoveLeg, DispatchRouteStop, RouteRouteLeg: List, Get
CalendarsCalendar. Events, availability, business hours and links count as Update on the calendar.Calendar: List, Get
EquipmentEquipmentEquipment: List, Get
Users and accessUserEmployee, OrganizationUser, UserGroup (its roles and members count as Update). Organization: Update. Secret: Update, Delete.User, UserEmployee, OrganizationUser, OrganizationConnection, UserGroup, UserGroupRole, ApplicationUserRole, AppPermission, Role: List, Get
ConfigurationWorkflow, AppModule, AppManifest, OrganizationConfig, DivisionThe same entities, plus DocumentTemplate, EntityField, CustomField, AuditLog, Search: List, Get
Reference dataOrderStatus, CommodityStatus, ContactStatus, JobStatus, EquipmentStatus, DispatchRouteStatus, DispatchRouteStopStatus, OrderMoveStatus, OrderMoveLegStatus, CommodityType, PackageType, PackageCategory, EquipmentType, ContainerType, ContainerDescription, City, Country, State, Port, PostalCode, Currency, ModeOfTransportation, Vessel, Terminal, WarehouseLocation, WarehouseZone, Lane, EventDefinition, CustomCode, TagThe same entities: List, Get
OperationsTransmission, KnowledgeAsset. ActionEvent: Update, Delete. Notification: Create.Transmission, KnowledgeAsset, ActionEvent, TimePunch, TelemetryRecord, TelemetrySource, TelemetrySubjectState, EmployeeClockState: List, Get
Workflow runsAPI/Workflow/Execute (see Workflows)None

Turning It On​

ApiPermissions:Enabled is false by default. While it is off, the permission check runs no queries and logs nothing. Record ownership and workflow.permission do not depend on it.

"ApiPermissions": { "Enabled": true, "Mode": "Enforce" }

ApiPermissions:Mode is Enforce (the default) or Audit:

  • Enforce refuses a missing permission.
  • Audit logs a missing permission as a Warning, ApiPermissionAuditRefusal {OrganizationId} {UserId} {Permission} {Source}, and lets the request through.

To introduce permissions in an environment, set Enabled=true with Mode=Audit, fix the declarations and roles the audit log shows, then switch to Enforce.

Errors​

CheckGraphQLREST
Missing permission on a writeAUTH_NOT_AUTHORIZED, Missing permission <name>., extension permission403
Missing permission on a readThe field is null, with AUTH_NOT_AUTHORIZED403
Record ownershipFORBIDDEN, Not authorized for this record.401
workflow.permissionFORBIDDEN, You do not have permission to run '<workflow>'.401 (Public API: 403)

A refused write names one missing permission at a time, so a role missing several sees them in turn.

Best Practices​

  1. When a module adds a screen or action, declare the API name of every query, mutation and workflow it calls, with the same roles as the UI name that shows it.
  2. Use exact names for anything below full access. API/Commodity/* would also hand Delete to a role that only scans.
  3. Keep Administrator in roles like any other role. It has no code bypass, so the entry keeps working if the API/* wildcard is ever narrowed.
  4. To restrict a call, list only the roles that may make it, and check that no other role holds a covering wildcard.
  5. Give user-callable workflows their own permission rather than relying on API/Workflow/Execute, which opens every workflow without one.
  • Permissions: UI permission names and how components use them
  • Modules: the permissions: block in an app module
  • Workflow YAML: the permission and runAs keys
  • Security: how permissions, roles and user groups relate