---
name: permissions-and-contexts
description: "Fluent Commerce permission model: users, roles, permissions, context hierarchy, accretive scoping"
version: 2.0
source: "Fluent Commerce official docs — Users, Roles, and Permissions (9 Oct 2025)"
relevant-skills:
  - rule-scaffold
  - custom-code
  - feature-plan
  - workflow-builder
  - settings
  - trace
  - implementation-map
  - bootstrap
  - pre-deploy-check
  - module-deploy
  - workflow-deploy
  - retailer-config
  - e2e-test
  - mystique-builder
  - mystique-validate
---

# Permissions and Contexts

Fluent Commerce uses **role-based access control (RBAC)** with a three-level **context hierarchy**. Access is determined by the intersection of **who** (user), **what** (role → permissions), and **where** (context level + context ID).

---

## 1. Context Hierarchy

```
Account (tenant boundary)
  └── Retailer(s)       (brands, channels, regions)
       └── Location(s)  (stores, warehouses, DCs)
```

Three context levels, from broadest to narrowest:

| Context Level | Scope | Example |
|--------------|-------|---------|
| **ACCOUNT** | Entire tenant — all retailers, all locations, all data | Account-wide admin |
| **RETAILER** | One retailer and its locations | Brand-specific operator |
| **AGENT** (Location) | One physical location within a retailer | Store assistant at a single store |

The location-level context type in the GraphQL schema is `AGENT`, not `LOCATION`. This is a legacy naming quirk — `contextType: "AGENT"` with `contextId: "<locationId>"` scopes to that location.

**Context inheritance flows downward only — never horizontally across siblings.**

- ACCOUNT-level role → applies to ALL retailers and ALL locations in the account
- RETAILER-level role → applies to that retailer and ALL locations under it
- AGENT-level role → applies to that single location only
- **A role at Retailer 1 gives ZERO access to Retailer 2** — context does not cascade across siblings

```
Account (ADMIN at ACCOUNT/0)
  ├── Retailer 1 (covered ✓)        vs.    Account
  │     ├── Location A (covered ✓)           ├── Retailer 1 (ADMIN at RETAILER/1)
  │     └── Location B (covered ✓)           │     ├── Location A (covered ✓)
  └── Retailer 2 (covered ✓)                │     └── Location B (covered ✓)
        └── Location C (covered ✓)           └── Retailer 2 (NOT covered ✗)
                                                   └── Location C (NOT covered ✗)
```

---

## 2. Permissions (Granular Access Rights)

A **permission** is a single access right protecting one GraphQL or REST API operation. Permissions follow a `NOUN_VERB` naming convention:

| Permission | What it protects |
|-----------|-----------------|
| `ORDER_VIEW` | `orders` query |
| `ORDER_CREATE` | `createOrder` mutation |
| `ORDER_UPDATE` | `updateOrder` mutation |
| `PLUGIN_UPLOAD` | Plugin JAR deployment |
| `LOCATIONNETWORK_REMOVE` | Remove location from network |
| `PERMISSIONROLE_REMOVE` | Remove role assignment |

**Every GraphQL operation is protected by a permission:**
- Queries: `<ENTITY>_VIEW` (e.g., `ORDER_VIEW` protects the `orders` query)
- Create mutations: `<ENTITY>_CREATE`
- Update mutations: `<ENTITY>_UPDATE`

Additional permissions exist for specific functionality beyond CRUD (e.g., `PLUGIN_UPLOAD`, `LOCATIONNETWORK_REMOVE`).

**Retrieve all permissions** via GraphQL (requires ACCOUNT-level context):
```graphql
{
  permissions(first: 500) {
    edges { node { name } }
  }
}
```

---

## 3. Roles (Collections of Permissions)

A **role** is a named collection of permissions. Roles are assigned to users within a context.

**Built-in roles** (fixed names, present on every account):

| Role | What it contains | Watch out |
|------|-----------------|-----------|
| `GRAPHQL` | **131 permissions** (verified) — VIEW + CREATE + UPDATE for virtually every entity type, plus `GRAPHQL_VIEW`, `GRAPHQL_SAVE`, `METRICS_VIEW`, `ANALYTICS_VIEW` | **Not just "API access"** — it's a super-role. Assigning at ACCOUNT context = full data access everywhere. |
| `ADMIN` | Full account administration | Highest privilege level |
| `DEVELOPER` | Module and plugin management | Needed for `fluent module install` |
| `ORCHESTRATION_ADMIN` | Workflow CRUD and event operations | Needed for workflow deployment |
| `STORE_ASSISTANT` | Store app operations | Typically scoped to AGENT (location) context |

> **`GRAPHQL` role misconception:** Many implementations assign `GRAPHQL` at ACCOUNT level thinking it just "enables API access". In reality it grants 131 permissions (full CRUD for orders, fulfilments, inventory, settings, billing, payments, sourcing, etc.). For restricted users, create a custom role with just the specific permissions needed (include `GRAPHQL_VIEW` + `GRAPHQL_SAVE` to enable query/mutation execution) — no `GRAPHQL` role required.

**Custom roles** can be created for specific business needs (see §4 for full end-to-end example with context assignment):
```graphql
mutation {
  createRole(input: {
    name: "ORDER_VIEWER",
    permissions: [
      { name: "GRAPHQL_VIEW" },
      { name: "GRAPHQL_SAVE" },
      { name: "ORDER_VIEW" }
    ]
  }) { id name }
}
```

**Retrieve all roles and their permissions:**
```graphql
{
  roles(first: 1000) {
    edges {
      node {
        name
        permissions { name }
      }
    }
  }
}
```

**Query a user's roles:** `me { roles { role { name } } }` — returns a flat array `[{ role: { name } }]`, NOT a connection type.

---

## 4. Role Assignment with Context (The Key Mechanism)

When a role is assigned to a user, it is assigned **at a specific context level with a context ID**. This determines what data that role can access.

| Context Level | Context ID | Effect |
|--------------|-----------|--------|
| `ACCOUNT` | `0` | Role applies to all retailers and all locations |
| `RETAILER` | `<retailerId>` | Role applies to that retailer and its locations |
| `AGENT` | `<locationId>` | Role applies to that single location only (GraphQL uses `AGENT`, not `LOCATION`) |

### End-to-end: Custom role with scoped context

**Step 1 — Create the role** with only the permissions this persona needs:
```graphql
mutation {
  createRole(input: {
    name: "FINANCE_USER",
    permissions: [
      { name: "ORDER_VIEW" },
      { name: "CREDITNOTE_VIEW" },
      { name: "CREDITNOTE_CREATE" },
      { name: "CREDITNOTE_UPDATE" }
    ]
  }) {
    id
    name
  }
}
```

**Step 2 — Assign the role to a user with context** via `updateUser`:
```graphql
mutation($input: UpdateUserInput!) {
  updateUser(input: $input) {
    id
    username
    roles {
      role { name }
      contexts { contextType contextId }
    }
  }
}
```
Variables — give FINANCE_USER for retailers 42 and 87, with GRAPHQL scoped to the same retailers (NOT account-wide):
```json
{
  "input": {
    "id": "<userId>",
    "roles": [
      {
        "role": { "name": "GRAPHQL" },
        "contexts": [
          { "contextType": "RETAILER", "contextId": "42" },
          { "contextType": "RETAILER", "contextId": "87" }
        ]
      },
      {
        "role": { "name": "FINANCE_USER" },
        "contexts": [
          { "contextType": "RETAILER", "contextId": "42" },
          { "contextType": "RETAILER", "contextId": "87" }
        ]
      }
    ]
  }
}
```

**Result:** This user can call the GraphQL API and access credit memo operations, but ONLY for retailers 42 and 87. Zero access to other retailers.

> **WARNING: `GRAPHQL` is not just "API access"** — it is a built-in role containing **131 permissions** (VIEW, CREATE, UPDATE for virtually every entity type). Assigning `GRAPHQL` at `ACCOUNT` context gives full read/write access to ALL data across ALL retailers, making any custom role scoping redundant due to the accretive model. **To enforce least-privilege, always scope `GRAPHQL` to the same context level as your custom roles.**

**Important:** `updateUser.roles` is a **full replacement** — include ALL roles the user should have, not just the new one. Omitting an existing role removes it.

### Schema reference

```
UserRoleInput {
  role: RoleKey!                       → { name: String! }
  contexts: [RoleContextInput!]!       → [{ contextType: String!, contextId: ID! }]
}
```

A single role assignment can carry **multiple contexts** — e.g., one FINANCE_USER entry with contexts for retailers 42 and 87 (as shown above). No need for separate role entries per retailer.

### Examples from official docs

**Example 1 — Admin access to one retailer:**
User A1 has ADMIN role at RETAILER context for Retailer 1 → can access Retailer 1 data only.

**Example 2 — Admin access to multiple retailers:**
User A2 has ADMIN role at RETAILER context for Retailer 1 AND Retailer 2 → can access both. The context switcher in the UI allows filtering by retailer.

**Example 3 — Store access to specific locations:**
User S1 has STORE_ASSISTANT role at AGENT context for Location 1 AND Location 2 under Retailer 1 → can access Retailer 1 data filtered to those two locations. The context switcher filters by retailer-specific data (fulfilments only).

### Decision guide: Setting up a new user

Ask these questions in order:

**1. What does this user need to DO?** (determines permissions)

Map each action to a permission using `NOUN_VERB` convention:
- "View orders" → `ORDER_VIEW`
- "Create and update fulfilments" → `FULFILMENT_CREATE` + `FULFILMENT_UPDATE`
- "Manage workflows" → needs the built-in `ORCHESTRATION_ADMIN` role
- "Deploy plugins" → `PLUGIN_UPLOAD`
- Not sure? Query all permissions: `{ permissions(first:500) { edges { node { name } } } }`

**2. Does an existing role already cover this?** (avoids role proliferation)

Query existing roles: `{ roles(first:1000) { edges { node { name permissions { name } } } } }`. If a built-in or existing custom role already has the right permissions, reuse it.

**3. If no existing role fits — create a custom role** (§3)

Pick a descriptive name (e.g., `ORDER_VIEWER`, `WAREHOUSE_OPERATOR`, `FINANCE_AUDITOR`). Include only the permissions this persona needs — least privilege.

**4. What data should they see?** (determines context level)

| If they need access to... | Use context level | contextId |
|--------------------------|-------------------|-----------|
| Everything in the account | `ACCOUNT` | `0` |
| One or more specific retailers | `RETAILER` | `<retailerId>` (one entry per retailer) |
| One or more specific stores/warehouses | `AGENT` | `<locationId>` (one entry per location) |

**5. How should they access the API?**

Two approaches — pick one:

| Approach | When to use | How |
|----------|------------|-----|
| **Custom role with `GRAPHQL_VIEW`** | Restricted users — least-privilege | Include `GRAPHQL_VIEW` (and `GRAPHQL_SAVE` for mutations) in your custom role. No `GRAPHQL` role needed. |
| **`GRAPHQL` role scoped to retailer** | Quick setup — broad access within a retailer | Assign the built-in `GRAPHQL` role at `RETAILER` context. Grants 131 permissions for that retailer. |
| **`GRAPHQL` role at ACCOUNT** | Admin/dev users only | Full data access everywhere — effectively god mode for data. |

**Never assign `GRAPHQL` at ACCOUNT for restricted users** — it overrides any narrower scoping from custom roles (permissions are accretive).

**Query what permissions any role has:**
```graphql
{ roles(name: ["GRAPHQL"], first: 1) { edges { node { name permissions { name } } } } }
```

**6. Assign via `updateUser`** (§4 — end-to-end example above)

Remember: `roles` is a full replacement array. Include ALL roles the user should have.

### Quick recipe: "User who can only view orders for Retailer 1"

**Option A — Use GRAPHQL scoped to same retailer** (simple, but grants 131 permissions for that retailer):
```json
{
  "input": {
    "id": "<userId>",
    "roles": [
      {
        "role": { "name": "GRAPHQL" },
        "contexts": [{ "contextType": "RETAILER", "contextId": "<retailer1Id>" }]
      }
    ]
  }
}
```
Result: User gets all GRAPHQL permissions (VIEW + CREATE + UPDATE for all entities) but ONLY for Retailer 1. Simple but not truly least-privilege.

**Option B — Custom role only, no GRAPHQL role** (recommended — true least-privilege):
```graphql
# Step 1: Create role with only what's needed — GRAPHQL_VIEW enables query execution
mutation { createRole(input: {
  name: "ORDER_VIEWER",
  permissions: [{ name: "GRAPHQL_VIEW" }, { name: "ORDER_VIEW" }]
}) { id name } }
```
```json
{
  "input": {
    "id": "<userId>",
    "roles": [
      {
        "role": { "name": "ORDER_VIEWER" },
        "contexts": [{ "contextType": "RETAILER", "contextId": "<retailer1Id>" }]
      }
    ]
  }
}
```
Result: User can ONLY query orders for Retailer 1. No `GRAPHQL` role assigned — the `ORDER_VIEWER` custom role has `GRAPHQL_VIEW` which is sufficient to execute queries. Cannot create/update anything. Cannot see other entities. Cannot access other retailers.

> **The `GRAPHQL` role is a convenience bundle, not a prerequisite.** Any role with `GRAPHQL_VIEW` (for queries) or `GRAPHQL_SAVE` (for mutations) grants API execution ability. You don't need the `GRAPHQL` role itself — it just happens to contain those permissions along with 118 others.

---

## 5. Accretive (Additive) Permission Model

Permissions are **accretive** — they accumulate across all role assignments. There is no "deny" or "subtract".

**Rules:**
- A user can have **multiple roles**, each at different context levels
- Same permission across multiple roles with different contexts → access is the **union** of all contexts
- Higher context subsumes lower: ACCOUNT-level role makes RETAILER/AGENT assignments for the same role redundant
- Context inheritance: RETAILER-level role automatically covers all AGENT (location) contexts under that retailer

### Example: Accretive scoping

User has:
1. Role `FINANCE_USER` at `RETAILER` / `42` (permissions: ORDER_VIEW, CREDITNOTE_VIEW)
2. Role `FINANCE_USER` at `RETAILER` / `87` (permissions: ORDER_VIEW, CREDITNOTE_VIEW)
3. Role `ADMIN` at `ACCOUNT` / `0` (all permissions)

Result:
- FINANCE_USER permissions apply to retailers 42 and 87 (union)
- ADMIN permissions apply to ALL retailers and locations (account-level subsumes everything)
- Combined: user has all ADMIN permissions everywhere, plus FINANCE_USER is redundant (already covered by ADMIN at account level)

---

## 6. User Types

The `type` field on a user indicates the nature of the user. Roles define actual access regardless of type.

| Type | Purpose |
|------|---------|
| Standard (human) | Interactive login for OMS/Store app users |
| `API` | External system integrations, service accounts, platform-generated users |

---

## 7. Platform-Generated Users

Three users are auto-created by the platform. Know them, respect them:

| User | Format | Purpose | Can modify? |
|------|--------|---------|-------------|
| **Rubix** | `<ACCOUNT_NAME>-<retailerId>-rubix` (e.g., `ACME-2-rubix`) | Workflow engine identity for rule execution. `context.api()` in rules authenticates as this user. One per retailer. | **Yes, but with care.** It's a user like any other — you CAN assign additional roles with any context (including other retailers or account-level). Do not change the password. Default setup is single-retailer scoped, but this is a default, not a hard constraint. |
| **Retailer admin** | `<retailerName>_admin` (e.g., `store_ops_admin`) | Default admin user created when a retailer is created. | Yes — this is yours to modify. No backend dependency. |
| **Event user** | `<ACCOUNT_NAME>_event` (e.g., `ACME-event`) | Manages audit event record creation/updates in event storage. | **Do not modify.** |

Note: The official docs say `<account id>` but this refers to the **account name/identifier** (e.g., `ACME`), not a numeric ID. Verified via live query.

### Rubix user — critical implications for rule development

- `context.api()` inside a rule authenticates as the **Rubix user**, NOT the user who triggered the event
- If the Rubix user lacks required permissions (e.g., `ORDER_UPDATE`, `GRAPHQL`) for the retailer, rules fail silently or with 403
- The dispatching user's identity is NOT automatically available — pass explicitly via `@EventAttribute` (e.g., `actionedBy` from `{{me.username}}` in a user action form)
- **Common failure pattern:** "Works in Postman, fails in workflow" — the human user has permissions, but the Rubix user doesn't

---

## 8. User Management

Users with `ROLE_MANAGER` and `USER_MANAGER` roles at **ACCOUNT** context can perform user management: search, list, add, modify users.

**Inactivating a user** (recommended practice when someone leaves):
1. Update user status to `INACTIVE`
2. Change the password
3. Remove all roles
4. Changes sync to SSO/Ping Identity environment

**Configuration paths:**
- SSO via Corporate IDP Integration
- Direct management in OMS app User admin section
- SCIM Connector (Fluent-supported) for automated role/user sync

---

## 9. Configuring Permissions for Fluent Apps

Three levels of access control in the UI:

| Level | Mechanism | Reference |
|-------|-----------|-----------|
| **App access** | Restrict entire app to specific roles | `appRoles` in app-level manifest config |
| **User action buttons** | Restrict workflow actions to specific roles | `roles` property on page component user actions |
| **Data filtering** | Filter queries by retailer/location context | `{{activeRetailer.id}}`, `{{activeLocation.ref}}` in manifest queries |

---

## 10. Settings Context (Separate from Role Context)

Settings have their own context model using the same vocabulary:

| Setting Context | contextId | Meaning |
|----------------|-----------|---------|
| `ACCOUNT` | `0` | One instance, visible to all retailers |
| `RETAILER` | `<retailerId>` | Per-retailer instance, only visible within that retailer |

**Manifest settings** (`fc.mystique.manifest.*`) MUST always use `ACCOUNT` / `0`. Using `RETAILER` context for manifests is a legacy anomaly — never replicate it.

---

## 11. How Permissions Flow Through the System

```
User clicks button in OMS/Store app
  │
  │  (1) App checks user's roles for app-level access
  │  (2) Page checks user's roles for user action visibility
  │  (3) Transition API checks user has required permission
  │      at the correct context (retailer/location)
  │
  ▼
Workflow engine picks up event
  │
  │  (4) Switches to Rubix user for rule execution
  │      — context.api() = Rubix user's permissions
  │      — dispatching user's identity NOT carried forward
  │
  ▼
Rule executes (GraphQL queries, entity creation, etc.)
  │
  │  (5) Rubix user's permissions + context determine success
  │      — must have <ENTITY>_CREATE / <ENTITY>_UPDATE etc.
  │      — context must cover the retailer
  │
  ▼
Settings read/written by rules
  │
  │  (6) Setting context determines visibility
  │      — ACCOUNT settings: visible to all
  │      — RETAILER settings: only matching retailerId
```

---

## 12. CLI and Deployment Permissions

**Profile authentication:** CLI profiles in `~/.fluentcommerce/` store credentials per account.

**Retailer credential overrides:** `~/.fluentcommerce/<PROFILE>/retailer.<ref>.json` overrides the profile-level user for all CLI commands targeting that retailer.

| Operation | Typical permission needed |
|-----------|--------------------------|
| `fluent module install` (plugins) | `PLUGIN_UPLOAD` or `ADMIN` |
| `fluent module install --include workflows` | `ORCHESTRATION_ADMIN` or equivalent |
| `fluent module install --include settings` | `ADMIN` or setting-specific permissions |
| GraphQL queries / mutations | `GRAPHQL` + entity-specific permissions |

**CI/CD service account pattern:** Create a dedicated API user with a custom role (e.g., `CI_CD_DEPLOY`) containing `PLUGIN_UPLOAD` + `ORDER_VIEW` + `ORDER_UPDATE` + etc., scoped to `RETAILER` context per target retailer. Add `GRAPHQL` role for API access.

**Important:** Always reset retailer credential overrides after elevated deploys — they persist and affect ALL subsequent CLI commands.

---

## 13. Debugging Permission Issues

| Symptom | Likely cause | Check |
|---------|-------------|-------|
| Rule fails with 403 | Rubix user missing permission or retailer context | Query Rubix user's roles + context |
| Plugin deploy returns 403 | User lacks `PLUGIN_UPLOAD` | Check CLI profile user's roles |
| GraphQL query returns empty | User's permission doesn't cover target retailer | Verify role context covers the retailerId |
| User action button invisible | Usually NOT permissions — check `eventName`, `subtype`, `modules` first | See user-action-contract.md |
| Setting returns null in rule | Setting uses RETAILER context, rule queries without retailerId | Check setting contextType and Rubix user's context |
| Store app shows no data | User lacks AGENT context for the store | Add role at AGENT context for that locationId |
| Cross-retailer operation fails (waves, fulfilment) | Rubix user's roles only scoped to its own retailer context | Two fixes: (1) assign Rubix user an additional role at the other retailer's context, or (2) create separate entities per retailer. See §14. |

**Debug query — check a user's roles and contexts:**
```graphql
{
  users(username: ["<username>"], first: 1) {
    edges {
      node {
        username
        type
        status
        roles {
          role { name permissions { name } }
          contexts { contextType contextId }
        }
      }
    }
  }
}
```
Note: field is `contexts` (plural), NOT `context`. The `username` filter takes an array `["..."]`.

---

## 14. Cross-Retailer Constraints

### Default Rubix scope and cross-retailer access

By default, each retailer's Rubix user has roles scoped to its own retailer context only. But the Rubix user is a user like any other — you CAN assign additional roles with other retailer contexts or even account-level context. This would give that Rubix user cross-retailer access for workflow execution.

**Default behaviour (out of the box):** Retailer 2's workflow runs as retailer 2's Rubix user, which by default has no context coverage over retailer 1's data.

**Can be changed:** Assign retailer 2's Rubix user an additional role with retailer 1 context (or an account-level role) to grant cross-retailer access.

### Real-world example (confirmed via support ticket)

**Scenario:** Location 17000080 created under retailer 1 (Etro EU), assigned to network 3 (ETRO_EU_HD). Attempted to add the same location to network 6 (ETRO_US_HD) linked to retailer 2 (Etro US).

**Result:** Store app fails, wave creation fails for retailer 2.

**Root cause (default setup):** Retailer 2's Rubix user (`<ACCOUNT_NAME>-2-rubix`) only has roles at retailer 2 context. Location 17000080 belongs to retailer 1's context. The Rubix user has no context coverage to operate on it.

**Fix 1 — Expand Rubix user context (recommended when shared location is intentional):**

Create a custom role with only the permissions retailer 2's Rubix user needs for cross-retailer operations, then assign it at retailer 1's context:

```graphql
# Step 1: Create a minimal cross-retailer role
mutation {
  createRole(input: {
    name: "CROSS_RETAILER_LOCATION_OPS",
    permissions: [
      { name: "GRAPHQL_VIEW" },
      { name: "GRAPHQL_SAVE" },
      { name: "LOCATION_VIEW" },
      { name: "WAVE_CREATE" },
      { name: "WAVE_UPDATE" },
      { name: "FULFILMENT_VIEW" },
      { name: "FULFILMENT_CREATE" },
      { name: "FULFILMENT_UPDATE" }
    ]
  }) { id name }
}

# Step 2: Assign to retailer 2's Rubix user at RETAILER/1 context
# IMPORTANT: include ALL existing roles — updateUser.roles is a full replacement
mutation($input: UpdateUserInput!) {
  updateUser(input: $input) {
    id username
    roles { role { name } contexts { contextType contextId } }
  }
}
```
```json
{
  "input": {
    "id": "<rubix-user-id>",
    "roles": [
      // ... ALL existing roles the Rubix user already has (query first!) ...
      {
        "role": { "name": "CROSS_RETAILER_LOCATION_OPS" },
        "contexts": [{ "contextType": "RETAILER", "contextId": "<retailer1Id>" }]
      }
    ]
  }
}
```

Trade-off: retailer 2's workflow engine can now operate on retailer 1's location data. Scope the custom role to minimum permissions needed.

**Fix 2 — Duplicate the location (isolation approach):**

Create a separate location entity under retailer 2 for the same physical warehouse. Cleaner isolation but requires managing inventory for both entities.

### Multi-retailer location pattern (isolation approach)

One physical location = one Fluent location entity per retailer:

1. Create `WH_001_EU` under retailer 1 → add to ETRO_EU_HD network
2. Create `WH_001_US` under retailer 2 → add to ETRO_US_HD network
3. Manage inventory for both location refs (same physical stock, two Fluent entities)

### What about networks with multiple retailers?

`CreateNetworkInput.retailers` accepts `[RetailerId!]!` — a network CAN reference multiple retailers. But the **location entity** still belongs to one retailer's context. Adding it to a multi-retailer network doesn't change its ownership. The Rubix user still needs context coverage to operate on it.
