Skip to content

Permission Model Reference

The permissions model in Travomate Cargo is based on groups rather than rigid roles. Groups define which GraphQL queries and mutations users can call, and which fields are relevant on the way in and out of each one.

Group Basics

What is a Group?

A group is a collection of permissions that can be assigned to users. Instead of creating a new role for each scenario, you define groups with specific permissions and assign them to users as needed.

Benefits:

  • Flexible - Create groups for new business requirements
  • Reusable - Groups can be shared across many users
  • Inheritable - Groups can inherit from parent groups
  • Easy to manage - Change permissions once, all users get update

Group Structure

group:
  id: group_123
  name: "cargo_owner"
  description: "Users who post cargo for shipment"

  inheritsFrom:
    - parent_group_id # optional, can inherit permissions

  permissions:
    - operation: "cargoListing" # query
      fields:
        out: ["id", "title", "origin", "destination"]

    - operation: "createCargoListing" # mutation
      fields:
        in: ["title", "origin", "destination", "price"]
        out: ["id", "status"]

Defining Permissions

Permission Structure

GraphQL doesn't have "resources" attached to controllers the way REST does. It has queries, mutations, and subscriptions, each identified by a unique name. So a permission is scoped to one operation, not a resource:

Each permission specifies:

  1. Operation - The query or mutation this permission applies to (cargoListing, createCargoListing, updateCargoListing, etc.). Operation names are unique across the schema, so no separate grouping key is needed.
  2. Fields - Which fields are allowed on the way in (mutation input) and out (out, what comes back in the response)
  3. Constraints (optional) - Additional conditions on when the operation is allowed (e.g., "only drafts", "only own resources")
permission:
  operation: "updateCargoListing"
  fields:
    in: ["title", "description"]
    out: ["id", "title", "state"]
  constraints:
    - condition: "state in ['draft', 'published']" # cannot update locked states

Read or Write Isn't a Declared Field

Whether an operation reads or writes is already implied by whether it's defined under Query or Mutation in the schema. There's no separate action: read | write field to keep in sync. What a permission schema can't capture generically is finer-grained intent within a write: whether a given mutation is conceptually a create, an update, or a delete is a detail of what that resolver does internally, not something derivable from the API shape alone. That distinction is the responsibility of whoever implements the resolver. Pay attention to what's actually being created, updated, or deleted inside it, since the permission model won't catch a mismatch for you.

Where Operation Names Come From

Valid operation names aren't a hand-maintained list. They're derived from the schema itself. See Schema Permission Registry for how that registry is built and why it keeps group definitions from referencing an operation that doesn't exist.

State-Based Constraints

Operations can be constrained by the instance's state:

permission:
  operation: "updateCargoListing"
  constraints:
    - condition: "state in ['draft', 'published']"

permission:
  operation: "deleteCargoListing"
  constraints:
    - condition: "state == 'draft'"

When an instance is in state "matched", update and delete operations are blocked. A copy/snapshot is created instead.


Group Examples

Operations that aren't specific to any one side of the marketplace, like starting or reading a conversation, shouldn't be repeated in every group that needs them. They're grouped separately and pulled in through inheritance, while listing-specific operations for cargo owners and capacity providers are kept apart so each side only gets what applies to it.

Example 1: Communications (shared base)

group:
  name: "communications"
  inheritsFrom: []

  permissions:
    - operation: "createConversation"
      fields:
        in: ["participantId", "message"]
        out: ["id"]

    - operation: "conversation"
      fields:
        out: ["id", "participants", "lastMessage"]

Example 2: Basic Group (inherits Communications)

basic_group is the common parent every marketplace-side user ends up with. It contributes no permissions of its own. It exists purely to carry communications into cargo_owner and capacity_provider, so messaging operations are defined once and inherited everywhere instead of being copied into every listing-side group.

group:
  name: "basic_group"
  inheritsFrom: ["communications"]

  permissions: []

Example 3: Cargo Owner (inherits Basic Group, adds cargo-specific permissions)

Cargo-listing operations only make sense for cargo owners, so they're assigned directly to cargo_owner rather than pushed up into basic_group or communications:

group:
  name: "cargo_owner"
  inheritsFrom: ["basic_group"]

  permissions:
    - operation: "cargoListing"
      fields:
        out: ["id", "title", "origin", "destination", "price", "internalNotes"]

    - operation: "createCargoListing"
      fields:
        in: ["title", "origin", "destination", "price"]
        out: ["id", "status"]

    - operation: "updateCargoListing"
      fields:
        in: ["title", "description", "price"]
        out: ["id", "title", "state"]
      constraints:
        - condition: "state in ['draft', 'published']"

    - operation: "deleteCargoListing"
      constraints:
        - condition: "state == 'draft'"

    - operation: "capacityListing"
      fields:
        out: ["id", "title", "origin", "destination", "price", "providerName"]

    - operation: "updateShipmentStatus"
      fields:
        in: ["status"]
        out: ["id", "status"]

cargo_owner's effective permissions are the union of this list plus everything basic_group and, transitively, communications grant. capacity_provider is built the same way. It inherits basic_group and adds the mirror-image capacity-listing operations instead of cargo-listing ones.


Example 4: Verified Provider (inherits capacity_provider)

group:
  name: "verified_provider"
  inheritsFrom: ["capacity_provider"] # inherits capacity_provider, which in turn inherits basic_group and communications

  additional_permissions:
    - operation: "shipmentAnalytics"
      fields:
        out: ["totalShipments", "onTimeRate", "avgRating"]

    - operation: "createPremiumListing"
      fields:
        in: ["title", "origin", "destination", "price", "featured"]
        out: ["id", "status"]

    - operation: "updatePremiumListing"
      fields:
        in: ["title", "price", "featured"]
        out: ["id", "state"]

    - operation: "deletePremiumListing"

With inheritance, verified_provider gets all capacity_provider permissions PLUS the additional ones.


Example 5: Moderator (custom)

group:
  name: "moderator"
  inheritsFrom: []

  permissions:
    - operation: "cargoListing"
      fields:
        out: ["id", "title", "origin", "destination"]

    - operation: "hideCargoListing"
    - operation: "unhideCargoListing"

    - operation: "capacityListing"
      fields:
        out: ["id", "title", "origin", "destination"]

    - operation: "hideCapacityListing"
    - operation: "unhideCapacityListing"

    - operation: "flaggedConversations" # only returns conversations this group is assigned to
      fields:
        out: ["id", "participants", "lastMessage", "flagReason"]

    - operation: "moderateConversation"
      fields:
        in: ["action", "reason"]
        out: ["id", "status"]

    - operation: "userProfile"
      fields:
        out: ["publicProfile"] # public fields only

Permission Evaluation

How Permissions Are Checked

When a user attempts an operation, the system evaluates:

  1. User's groups - Get all groups assigned to user
  2. Collect permissions - Gather all permissions from all groups (including inherited parents)
  3. Union permissions - Combine into a single set of allowed operations
  4. Check operation - Is this operation in the user's effective permission set?
  5. Check fields - Are the requested in/out fields allowed for this operation?
  6. Check constraints - Does the instance state match any required constraints?
  7. Check visibility - Is user allowed to see this instance? (see Visibility & Privacy)

Example evaluation:

User A performs: updateCargoListing (id: list_123)

1. User A's groups: [cargo_owner, premium_subscriber]

2. Permissions from cargo_owner:
   - updateCargoListing (fields: [title, description, price], state: draft|published)

3. Permissions from premium_subscriber:
   - no additional updateCargoListing perms

4. Effective permission: updateCargoListing (fields: [title, description, price], state: draft|published)

5. Check listing state: list_123.state = "matched"
   → NOT in allowed states (draft, published)
   → DENIED

6. System response:
   "Cannot update listing in 'matched' state.
    Create a snapshot to propose changes."

Permission Evaluation with Multiple Groups

Union Model

When a user is assigned to multiple groups, their effective permissions are the union of all permissions from those groups. There is no conflict resolution. A permission either exists in the union or it doesn't.

Example:

User assigned to: [cargo_owner, premium_subscriber]

cargo_owner permissions:
  - cargoListing
  - createCargoListing
  - updateCargoListing (draft, published)
  - conversation

premium_subscriber permissions:
  - premiumAnalytics
  - advancedSearch

Result: User can perform all operations above (union of both)

No Explicit Deny

There is no deny list. If an operation is not listed in any of a user's groups, it's not allowed. This keeps the model simple and secure by default.

To restrict a user's access, use a different (lower-privilege) group, or ask an admin to remove the group that grants the unwanted permission.


Creating a Group

Via Admin Panel

In the admin panel's Groups Management screen, an admin fills in a group name, description, and an optional parent group to inherit from, then adds one or more permissions: each a named operation, its in/out fields, and any constraints (e.g. restricting deleteCargoListing to state = 'draft').

Via API

The same operation is available as a createGroup mutation, which takes a name, an optional description, an optional parent group to inherit from, and a list of permissions (each with an operation name, its fields, and any constraints). See GraphQL Implementation for the exact mutation shape.


Duplicating a Group

Purpose

Duplicating a group is a faster way to create a new group that starts from an existing group's permissions, instead of building one from scratch.

Structure only, not members

Duplicating a group copies its permissions, inheritsFrom, and denied_permissions into a new group. It does not copy the source group's assigned users. The new group starts with zero members. This keeps duplication a template operation: the admin still decides who gets assigned to the new group.

Via Admin Panel

From an existing group's page (e.g. verified_shipper), an admin chooses Duplicate, gives the new group a name and description (e.g. verified_shipper_eu), and reviews the copied settings before confirming. The source group's inheritsFrom, permissions, and denied permissions carry over, but its member count starts at zero.

Via API

The same operation is available as a duplicateGroup mutation, which takes the source group's ID and a name (and optional description) for the new group, and returns the newly created group with its copied permissions and a member count of zero. See GraphQL Implementation for the exact mutation shape.


Modifying Groups

Updating Default (Seeded) Groups

The groups shown in the examples above - communications, basic_group, cargo_owner, capacity_provider, verified_provider, admin, moderator - aren't just documentation examples. They're read from api/src/main/resources/groups.yaml and upserted into the database by DefaultGroupsSeeder, matched by group name, every time the API starts.

To change what a default group grants - for example, adding a new operation to communications so every marketplace-side user inherits it - edit that group's permissions list in groups.yaml and restart the API:

- name: communications
  permissions:
    - operation: createConversation
      in-fields: [participantId, message]
      out-fields: [id]
    # ...existing permissions...
    - operation: userProfile
      out-fields: [publicProfile]

This is the correct place to make the change because:

  • The seeder is authoritative for these group names. On every restart it rewrites each default group's permissions to match groups.yaml exactly, so the change applies consistently to every environment that boots from this codebase - local dev, CI, staging, production - not just whichever database happened to get a manual edit.
  • It's also idempotent for existing users: because the upsert is by name (not a one-time insert), users already assigned to the group pick up the new permission on the next restart automatically. No migration or per-environment update script is needed.
  • Conversely, editing a default group through the admin panel or updateGroup at runtime is not persistent - the next API restart overwrites it back to whatever groups.yaml says. Use groups.yaml as the source of truth for these groups; treat the admin panel as a way to inspect or test, not to permanently change them.

Custom groups created at runtime (via the admin panel or createGroup) are unaffected by the seeder - it only touches groups whose name matches an entry in groups.yaml.

Update Permissions

Permissions are updated by calling updateGroup with the group's ID and its new permission list. See GraphQL Implementation for the mutation.

Note: When you modify a group, all users in that group immediately get the new permissions. For one of the default (seeded) groups listed above, this only lasts until the next API restart - see Updating Default (Seeded) Groups to make a change stick.

Delete Group

Groups are removed by calling deleteGroup with the group's ID. See GraphQL Implementation for the mutation.

When deleting a group:

  • All users in that group lose those permissions
  • Child groups that inherit from it are affected
  • Users' effective permissions are recalculated

Time-Based Scope

Permissions can expire:

permission:
  operation: "premiumAnalytics"
  valid_until: "2025-12-31" # expires at end of year

Audit & Logging

All permission changes are logged:

Timestamp Action Detail By
2025-01-15 10:30 Group created verified_shipper, inheritsFrom=cargo_owner, added permissions: [shipmentAnalytics] admin_user
2025-01-16 14:22 User assigned to group john@company.com: [cargo_owner][cargo_owner, verified_shipper] admin_user

Permission changes take effect immediately for all affected users.