> ## Documentation Index
> Fetch the complete documentation index at: https://docs.modulex.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Roles & permissions

> The ModuleX organization role model: owner and admin are the only live roles, member is retired, and which actions require owner versus admin across workflows, the AI Composer, the Assistant, schedules, knowledge, credentials, and member management.

export const MediaEmbed = ({id, type = 'screenshot', caption = '', ext, ratio = '16 / 9'}) => {
  const isVideo = type === 'video' || type === 'app_video';
  const resolvedExt = ext || (isVideo ? 'mp4' : type === 'screenshot' ? 'webp' : 'svg');
  const src = 'https://media.modulex.dev/' + id + '.' + resolvedExt;
  const [status, setStatus] = useState('loading');
  const [isDev, setIsDev] = useState(false);
  const [inView, setInView] = useState(false);
  const boxRef = useRef(null);
  useEffect(() => {
    if (typeof window === 'undefined') return;
    const h = window.location.hostname;
    setIsDev(h === 'localhost' || h === '127.0.0.1' || h.endsWith('.mintlify.app'));
  }, []);
  useEffect(() => {
    if (inView) return;
    if (typeof IntersectionObserver === 'undefined') {
      setInView(true);
      return;
    }
    const el = boxRef.current;
    if (!el) return;
    const io = new IntersectionObserver(entries => {
      if (entries.some(e => e.isIntersecting)) {
        setInView(true);
        io.disconnect();
      }
    }, {
      rootMargin: '300px'
    });
    io.observe(el);
    return () => io.disconnect();
  }, [inView]);
  if (status === 'missing') {
    if (!isDev) return null;
    return <div style={{
      display: 'flex',
      flexDirection: 'column',
      justifyContent: 'center',
      gap: '0.4rem',
      padding: '1rem 1.25rem',
      margin: '1.25rem 0',
      width: '100%',
      aspectRatio: ratio,
      boxSizing: 'border-box',
      border: '1px dashed rgba(128,128,128,0.45)',
      borderRadius: '0.75rem',
      background: 'rgba(128,128,128,0.06)',
      color: 'currentColor',
      fontSize: '0.85rem',
      lineHeight: 1.45
    }}>
        <div style={{
      display: 'flex',
      alignItems: 'center',
      gap: '0.5rem',
      opacity: 0.75
    }}>
          <span aria-hidden="true">🎬</span>
          <code style={{
      fontSize: '0.75rem'
    }}>{id}</code>
          <span style={{
      fontSize: '0.65rem',
      textTransform: 'uppercase',
      letterSpacing: '0.04em',
      padding: '0.1rem 0.4rem',
      borderRadius: '0.4rem',
      background: 'rgba(128,128,128,0.18)'
    }}>
            {type}
          </span>
        </div>
        <div style={{
      opacity: 0.9
    }}>{caption || 'Media not uploaded yet.'}</div>
        <div style={{
      fontSize: '0.7rem',
      opacity: 0.5
    }}>
          Upload to R2 as <code>{id}.{resolvedExt}</code> — preview only, hidden in production.
        </div>
      </div>;
  }
  const mediaStyle = {
    display: status === 'loaded' ? 'block' : 'none',
    width: '100%',
    height: 'auto',
    borderRadius: '0.75rem'
  };
  const media = isVideo ? <video src={inView ? src : undefined} autoPlay loop muted playsInline preload="metadata" onLoadedData={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} /> : <img src={inView ? src : undefined} alt={caption} onLoad={() => setStatus('loaded')} onError={() => setStatus('missing')} style={mediaStyle} />;
  return <figure style={{
    margin: '1.25rem 0'
  }}>
      <div ref={boxRef} style={status === 'loaded' ? {
    width: '100%'
  } : {
    width: '100%',
    aspectRatio: ratio,
    borderRadius: '0.75rem',
    background: 'rgba(128,128,128,0.06)'
  }}>
        {media}
      </div>
      {status === 'loaded' && caption ? <figcaption style={{
    marginTop: '0.5rem',
    textAlign: 'center',
    fontSize: '0.85rem',
    opacity: 0.7
  }}>
          {caption}
        </figcaption> : null}
    </figure>;
};

Every action inside an [organization](/concepts/organizations-roles) is checked against your role in that organization. ModuleX keeps the model deliberately small: there are two live roles, **owner** and **admin**, and the role you hold is verified on every request, not just at sign-in.

This page is the exhaustive reference for that model: the live roles, the gate each kind of action requires, the exact errors you get when a check fails, and the edge cases that come from a role being retired mid-life. For the conceptual tour of tenancy and how the role attaches to your membership, see [Organizations, roles & membership](/concepts/organizations-roles).

<Note>
  Roles are resolved per request from your membership in the organization named by the `X-Organization-ID` header. The same role check runs whether the request comes from the app (a Clerk sign-in) or from code (an `mx_live_` API key). See [Org context & X-Organization-ID](/security/org-context) for how that header is validated.
</Note>

## The live roles

An organization role is one of exactly two values.

<CardGroup cols={2}>
  <Card title="Owner" icon="crown">
    Assigned to whoever creates the organization. An owner can do everything an admin can, plus a small set of owner-only actions such as removing a member. Owner is the only role that cannot be assigned later.
  </Card>

  <Card title="Admin" icon="user-gear">
    Full working access: create, edit, run, and deploy workflows, use the AI Composer and the Assistant, manage knowledge and credentials, schedule runs, and invite teammates. Everyone invited to an organization joins as an admin.
  </Card>
</CardGroup>

A few rules govern how those roles are set:

* The role lives on your organization membership record, with a column default of `admin`.
* **Owner is assignable only at organization-creation time.** The person who creates the organization becomes its owner; no API can promote another member to owner afterward. Role-changing requests are validated against the pattern `^admin$` at the edge, so `owner` is rejected as an input value.
* An organization can have more than one owner only through ownership transfer mechanics that are not exposed as a public role-update operation today. Treat owner as fixed at creation unless you remove and recreate the organization.

<Note>
  There is a separate, account-level role on the underlying user record (`USER` versus `SUPER_ADMIN`) used only by ModuleX platform staff. It is distinct from your organization role and is not something customers assign. This page covers organization roles only.
</Note>

### The member role is retired

ModuleX previously had a third role, `member`, that granted read-only access. It was **retired on 2026-06-20**. The live roles are **owner** and **admin** only.

<Warning>
  There is no member role you can assign. If you see `member` referenced in an old export, a legacy membership row, or a third-party type definition, treat it as deprecated. New invitations and role changes accept only `admin` (owner is creation-only), and the role checks below recognize only `owner` and `admin`.
</Warning>

Because the role was retired in place rather than fully purged, a few legacy mentions of `member` can still surface. None of them let you grant the role through a supported path, but they are worth knowing about:

<AccordionGroup>
  <Accordion title="Old membership rows may still carry member">
    A membership created before 2026-06-20 can still hold the literal value `member` in the database, and an unfiltered organization listing can still return such rows. The write gates below do not treat `member` as owner or admin, so a legacy member row has neither admin nor owner permissions on the gated surfaces.
  </Accordion>

  <Accordion title="The membership-list filter still accepts member as a query value">
    `GET /auth/me/organizations` accepts an optional `role` filter whose documented values are `member`, `admin`, and `owner`. Passing `member` filters for legacy rows; it does not create or imply a usable role. Prefer filtering by `owner` or `admin`.
  </Accordion>

  <Accordion title="The realtime layer still lists member in its role union">
    The realtime (Socket.io) collaboration server still names `member` in its internal role union and would grant such a connection read-only canvas access. This only matters for legacy rows; admins and owners get full read and write. See the caveat in [Presence, locks & versioning](/realtime/presence-locks).
  </Accordion>
</AccordionGroup>

## How a role is enforced

Every org-scoped route runs the same two-stage check before it does any work:

<Steps>
  <Step title="Membership is verified">
    ModuleX reads the `X-Organization-ID` header, confirms you are an active member of that organization, and resolves your role in it. A missing header, an inactive account, or a non-membership stops the request here. See [Org context & X-Organization-ID](/security/org-context) for the membership errors.
  </Step>

  <Step title="The role gate is applied">
    The route then requires either owner/admin or owner-only, depending on the action. If your resolved role is not high enough, the request is rejected with a `403` before any side effect occurs.
  </Step>
</Steps>

There are three role gates in the backend. Almost every product action uses the admin gate.

<ResponseField name="Membership gate" type="any member of the org">
  Requires only that you are an active member of the organization named in `X-Organization-ID`. Used for personal, non-privileged actions such as leaving an organization. Resolved by the `organization_membership_required` dependency.
</ResponseField>

<ResponseField name="Admin gate (owner or admin)" type="owner or admin">
  Requires your role to be `owner` or `admin`. This is the gate on the working surfaces: workflows, the AI Composer, the Assistant, schedules, knowledge bases, and credentials. Resolved by the `organization_admin_required` dependency.
</ResponseField>

<ResponseField name="Owner gate (owner only)" type="owner">
  Requires your role to be exactly `owner`. Reserved for a small set of organization-administration actions, such as removing another member. Resolved by the `organization_owner_required` dependency.
</ResponseField>

## What each role can do

The table below maps every privileged surface to the role it requires. "Admin" means owner or admin (owners always satisfy the admin gate); "Owner" means owner only.

| Surface / action                                                                                | Minimum role       | Notes                                                             |
| ----------------------------------------------------------------------------------------------- | ------------------ | ----------------------------------------------------------------- |
| Create, list, read, update, delete a [workflow](/workflow-builder/overview)                     | Admin              | All builder CRUD is admin-gated.                                  |
| [Run a workflow](/workflow-builder/execution/api-endpoint) and stream, resume, or cancel a run  | Admin              | Includes `POST /workflows/run`, `listen`, `resume`, and `cancel`. |
| Deploy a workflow and manage [deployments & versions](/workflow-builder/execution/deploy)       | Admin              | Deploy, activate, and delete deployments.                         |
| Use the [AI Composer](/workflow-builder/composer) (generate, edit, save, revert a workflow)     | Admin              | Every Composer route is admin-gated.                              |
| Use the [Assistant](/assistant/overview) (start a chat, send messages, resume, cancel)          | Admin              | Every Assistant route is admin-gated.                             |
| Create, update, run, pause, or delete a [schedule](/workflow-builder/execution/schedule)        | Admin              | Every schedule route is admin-gated.                              |
| Manage [knowledge bases](/platform/knowledge/overview) and documents, and run searches          | Admin              | All knowledge routes are admin-gated.                             |
| Manage [credentials](/concepts/credentials-oauth): create, update, delete, test, OAuth2 connect | Admin              | All credential routes are admin-gated.                            |
| View the organization LLM catalog and [org settings](/concepts/organizations-roles)             | Admin              | Catalog, model visibility, and the default Composer model.        |
| Invite a teammate, cancel or resend an invitation, change a member's role                       | Admin              | Role changes accept only `admin` as the target value.             |
| Remove a member from the organization                                                           | **Owner**          | The only routine owner-only product action.                       |
| Create an [API key](/security/authentication) and create an organization                        | Any signed-in user | Not org-role-gated; key creation uses your account identity.      |
| Leave an organization                                                                           | Any member         | Owners cannot leave their own organization.                       |

<Note>
  The Composer, the Assistant, and schedules all require **owner or admin**. Some source comments describe the chat surfaces as "available to every member" — that comment is inaccurate. As shipped, those surfaces are admin-gated and a legacy member row is rejected with a `403`.
</Note>

### Admin can change roles, but not create owners

Promoting or demoting a teammate is an admin action with built-in guardrails. When you call the role-update operation:

* The only accepted target role is `admin` (the request body is validated against `^admin$`). You cannot set `owner` through this route.
* You cannot change your own role.
* You cannot change the role of an owner.
* An admin cannot change the role of another admin; that is an owner-level conflict and is rejected.

These guardrails mean ownership is effectively fixed at creation, and the role surface that an admin can actually move is narrow.

## Managing roles and membership

Role and membership management is exposed over REST and works the same from the app or from code. Authenticate every request with your API key as a bearer token plus the organization header, exactly as elsewhere in the API.

<CodeGroup>
  ```bash cURL theme={null}
  # List your organizations and the role you hold in each
  curl https://api.modulex.dev/auth/me/organizations?role=admin \
    -H "Authorization: Bearer mx_live_2J9vK4xM7pQ1sR8tU3wY6zB"

  # Invite a teammate (joins as admin) — requires owner or admin
  curl -X POST https://api.modulex.dev/organizations/invite \
    -H "Authorization: Bearer mx_live_2J9vK4xM7pQ1sR8tU3wY6zB" \
    -H "X-Organization-ID: 8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c" \
    -H "Content-Type: application/json" \
    -d '{"invited_email":"teammate@acme.com","role":"admin"}'

  # Update a member's role — requires owner or admin; only "admin" is accepted
  curl -X PUT https://api.modulex.dev/organizations/8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c/users/USER_ID/role \
    -H "Authorization: Bearer mx_live_2J9vK4xM7pQ1sR8tU3wY6zB" \
    -H "X-Organization-ID: 8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c" \
    -H "Content-Type: application/json" \
    -d '{"role":"admin"}'

  # Remove a member — OWNER ONLY
  curl -X DELETE https://api.modulex.dev/organizations/8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c/users/USER_ID \
    -H "Authorization: Bearer mx_live_2J9vK4xM7pQ1sR8tU3wY6zB" \
    -H "X-Organization-ID: 8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c"
  ```

  ```python Python theme={null}
  import asyncio
  from modulex import Modulex

  async def main():
      async with Modulex(
          api_key="mx_live_2J9vK4xM7pQ1sR8tU3wY6zB",
          organization_id="8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c",
      ) as client:
          # List your organizations and the role you hold in each
          orgs = await client.auth.organizations(role="admin")
          print(orgs)

  asyncio.run(main())
  ```

  ```javascript JavaScript theme={null}
  import { Modulex } from "modulex-js";

  const client = new Modulex({
    apiKey: "mx_live_2J9vK4xM7pQ1sR8tU3wY6zB",
    organizationId: "8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c",
  });

  // List your organizations and the role you hold in each
  const orgs = await client.auth.organizations({ role: "admin" });
  console.log(orgs);
  ```
</CodeGroup>

<Note>
  Membership and invitation methods have partial SDK coverage. Listing your organizations is available in both SDKs; invitations and role changes may be REST-only on a given SDK version. Check the [SDK ⇄ API parity matrix](/sdks/parity) before relying on a specific method, and fall back to a direct REST call when a method is absent.
</Note>

<MediaEmbed id="MX-MEDIA-4240" type="screenshot" caption={"The organization members management screen showing each teammate's role as owner or admin, with the invite and role controls."} />

## Inviting teammates

Invited teammates always join as **admin**. Two things gate an invitation:

<Steps>
  <Step title="Your role must be owner or admin">
    The invite, cancel, and resend operations are admin-gated. A legacy member row cannot invite.
  </Step>

  <Step title="Your plan must allow teammates">
    Inviting teammates is a paid-plan entitlement. On a Free plan, the invite is rejected with a `403` carrying the code `TEAMMATES_NOT_ALLOWED`, prompting an upgrade. The same entitlement is re-checked when the invitee accepts. See [Plans & pricing](/billing/plans).
  </Step>
</Steps>

Accepting or rejecting an invitation is done by the invited person and requires only that they are signed in; it is not gated on an organization role, because they are not yet a member. Owners cannot leave their own organization, so the last administrative tie to an organization is always an owner.

## Errors

Role and membership failures come back as standard FastAPI error envelopes — a JSON object with a `detail` field. (The flat billing `DenialEnvelope` documented on [Usage gating & limits](/billing/usage-gating) applies to usage/quota denials on the run, Composer, Assistant, and managed-knowledge surfaces; it is separate from the role errors here. The full taxonomy is on [Errors & status codes](/api-reference/errors).)

| Status | When                                                             | Response `detail`                                                                     |
| ------ | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| `400`  | The `X-Organization-ID` header is missing on an org-scoped route | `"X-Organization-ID header is required"`                                              |
| `401`  | No or invalid credential                                         | `"Authentication required. Provide Authorization: Bearer token or X-API-KEY header."` |
| `403`  | Active user but not a member of the named organization           | `"User is not a member of organization {organization_id}"`                            |
| `403`  | Member, but not owner/admin on an admin-gated route              | `"Admin or owner role required in organization {organization_id}"`                    |
| `403`  | Owner/admin, but not owner on an owner-only route                | `"Owner role required in organization {organization_id}"`                             |
| `403`  | API key scoped to a different organization than the header       | `"API key is scoped to a different organization"`                                     |
| `403`  | Inviting a teammate on a plan without the entitlement            | `{"message": "...", "code": "TEAMMATES_NOT_ALLOWED"}`                                 |
| `400`  | An owner attempts to leave the organization                      | `"Organization owners cannot leave..."`                                               |

<Expandable title="Example: admin-gate 403">
  A request from a legacy member (or any non-admin) to an admin-gated route such as `POST /workflows/run`:

  ```json theme={null}
  {
    "detail": "Admin or owner role required in organization 8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c"
  }
  ```
</Expandable>

<Expandable title="Example: owner-only 403">
  A request from an admin (not the owner) to remove a member via `DELETE /organizations/{organization_id}/users/{user_id}`:

  ```json theme={null}
  {
    "detail": "Owner role required in organization 8a1f3c2e-9b4d-4e7a-bc11-7d6e5f4a3b2c"
  }
  ```
</Expandable>

## Edge cases & gotchas

<AccordionGroup>
  <Accordion title="Owner cannot be assigned after creation">
    The role-update route validates the target role against `^admin$`, so `owner` is never an accepted value. The creator is the owner; there is no supported public operation to promote a second member to owner. Plan ownership at the point you create the organization.
  </Accordion>

  <Accordion title="An admin cannot demote or change another admin">
    Role changes are admin-gated, but an admin acting on another admin is rejected. This keeps admins from removing each other's access; only the broader account/ownership context resolves such conflicts. You also cannot change your own role.
  </Accordion>

  <Accordion title="API-key org scope is enforced on top of your role">
    An API key can be locked to a single organization. If a request uses that key with a different `X-Organization-ID`, it is rejected with `403` before the role check even runs. A correctly scoped key still grants only the role you hold in that organization — scoping a key does not elevate it. See [Auth model: JWT vs API key](/security/authentication).
  </Accordion>

  <Accordion title="Realtime role changes can lag behind the database">
    The realtime collaboration server caches your organization membership and role for a short window (up to about five minutes). A role change made over REST is enforced immediately on REST calls, but a live Socket.io session may not reflect the new role until the cache expires or the connection is re-established. Reconnect to force an immediate re-check. See [Presence, locks & versioning](/realtime/presence-locks).
  </Accordion>

  <Accordion title="Membership is re-checked on every request, not cached at sign-in">
    On the REST surface, your role is resolved fresh for each call from your current membership. Removing a member or changing a role takes effect on their next REST request, not on a session timer.
  </Accordion>
</AccordionGroup>

## Related pages

<CardGroup cols={2}>
  <Card title="Organizations, roles & membership" icon="users" href="/concepts/organizations-roles">
    The conceptual model for tenancy, membership, and how your role attaches to an organization.
  </Card>

  <Card title="Org context & X-Organization-ID" icon="route" href="/security/org-context">
    How the organization header is validated and how membership is checked on every request.
  </Card>

  <Card title="Auth model: JWT vs API key" icon="key" href="/security/authentication">
    The two authentication paths and how API-key organization scoping interacts with roles.
  </Card>

  <Card title="Permissions & limits (Assistant)" icon="robot" href="/assistant/permissions-and-limits">
    Who can use the Assistant and the usage limits that apply on top of the role gate.
  </Card>
</CardGroup>
