Permissions & Authorization#

This guide explains how authentication and authorization work in the Notifications Service, including special cases for storage events and configurable per-event-type permissions.

Overview#

The Notifications Service integrates with a permissions API to control:

  • Who can publish events of specific types

  • Who can create, delete, and consume from durable queues

  • Who can consume specific events from non-durable queues

  • Fine-grained access control based on event content

Authentication#

All requests to both the Event Aggregation Service and Event Consumer Service require authentication via bearer tokens.

Including Auth Token#

REST API:

Authorization: Bearer YOUR_JWT_TOKEN

gRPC:

metadata = [('authorization', 'Bearer YOUR_JWT_TOKEN')]
stub.PublishEvent(request, metadata=metadata)

Token Expiration#

Important: Tokens can expire while you’re streaming events. When this happens:

  1. The stream will terminate with a 401 (UNAUTHENTICATED) error

  2. Your client must refresh the token

  3. Reconnect with the fresh token

    • Non-durable queues: Use the reconnect_token to avoid missing events

    • Durable queues: Simply reconnect with the same queue_id

See the Consuming Events guide for code examples.

Publishing Permissions#

Event Type Permissions#

To publish an event, the authenticated principal must have permission for that specific event type.

The Event Aggregation Service checks permissions using:

  • Action: publish-event

  • Resource Type: EventType

  • Resource ID: The event_type string (e.g., “storage.file.created”)

Example Policy#

In your permissions service (e.g., Cedar policy):

// Allow user to publish storage events
permit(
  principal == Principal::"user@example.com",
  action == Action::"event-aggregation-service:publish-event",
  resource == EventType::"storage.file.created"
);

// Allow a service to publish workflow events
permit(
  principal == Principal::"workflow-service",
  action == Action::"event-aggregation-service:publish-event",
  resource == EventType::"workflow.completed"
);

REST Example#

curl -X POST https://your-aggregation-service.example.com/api/v1beta/events \
  -H "Authorization: Bearer YOUR_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "event": {
      "event_type": "myapp.user.created",
      ...
    }
  }'

If the principal doesn’t have publish-event permission for EventType::"myapp.user.created", you’ll receive:

HTTP 403 Forbidden
{
  "detail": "Not allowed to publish events of type myapp.user.created"
}

Configuration#

The Event Aggregation Service is configured via environment variables:

Variable

Description

OMNI_EVENTS_PERMISSIONS_ENABLED

Turns permission checks on (true) or off (false, the default). While it is off, every check is allowed.

OMNI_EVENTS_PERMISSIONS_ENDPOINT

URL of the permissions service. Required when OMNI_EVENTS_PERMISSIONS_ENABLED is true.

OMNI_EVENTS_PERMISSIONS_TTL_SECONDS

Cache TTL for permissions checks (default: 600)

The Event Consumer Service reads the same three variables. Its per-queue durable-queue switch is described under Per-Queue Authorization.

Example:

OMNI_EVENTS_PERMISSIONS_ENABLED=true
OMNI_EVENTS_PERMISSIONS_ENDPOINT=https://permissions.example.com
OMNI_EVENTS_PERMISSIONS_TTL_SECONDS=300

Consuming Permissions#

Consuming permissions are more complex because they involve:

  1. General permissions for queue management

  2. Special handling for storage events

  3. Optional fine-grained per-event-type permissions

Durable Queue Permissions#

To work with durable queues, principals need specific permissions:

There are three of them — create-durable-queues, consume-durable-queues, and delete-durable-queues — covering four operations: updating a queue’s filters is authorized by create-durable-queues, and there is no separate update action. Creating a queue with an id that already exists attaches to that queue, and is authorized as a create.

Each operation is checked before the service looks the queue up, so a refused call gets PERMISSION_DENIED (HTTP 403) whether or not the queue exists.

How far a grant reaches depends on whether the Event Consumer Service enforces per-queue authorization, which is off by default:

  • Deployment-wide (the default). The checks name no resource. Any authenticated principal that knows a durable queue’s id can create (attach to), consume from, update or delete that queue if it holds the deployment-wide action, and receives only the events its own event permissions allow. Grant these actions to the service accounts that operate durable queues rather than to broad user groups — see Use Service Accounts for Durable Queues.

  • Per-queue. Each check names the queue it is about as the resource DurableQueue::"<queue_id>". A principal can create (attach to), consume from, update or delete a queue only if a policy grants it that action on that queue, or on every queue; knowing the id grants nothing by itself.

The policy examples below leave resource unconstrained, so each one covers every durable queue in both modes.

Create Durable Queue#

  • Action: create-durable-queues

  • Resource: None by default. With per-queue authorization, DurableQueue::"<queue_id>". A create that omits queue_id is checked against the id the service assigns to the new queue, so it needs a grant that covers every queue.

Policy Example:

permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:create-durable-queues",
  resource
);

Update Durable Queue#

  • Action: create-durable-queues

  • Resource: None by default. With per-queue authorization, DurableQueue::"<queue_id>".

Updating the filters on an existing durable queue is authorized by the same action as creating one, so the policy example above covers it. No update-durable-queues action exists.

Consume from Durable Queue#

  • Action: consume-durable-queues

  • Resource: None by default. With per-queue authorization, DurableQueue::"<queue_id>".

Policy Example:

permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:consume-durable-queues",
  resource
);

Delete Durable Queue#

  • Action: delete-durable-queues

  • Resource: None by default. With per-queue authorization, DurableQueue::"<queue_id>".

Policy Example:

permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:delete-durable-queues",
  resource
);

Per-Queue Authorization#

Per-queue authorization lets each durable queue have its own policies: for example, service A may use queue A, service B may use its two queues, and administrators may delete any queue. It is optional and off by default.

With per-queue authorization on, each durable-queue check names its queue as the resource DurableQueue::"<queue_id>". The actions are the same three, and updating a queue stays under create-durable-queues. Each call is still one check, and the permission service decides it from your policies:

  • A policy whose resource names a queue, such as resource == DurableQueue::"service-a-events", applies to that queue only.

  • A policy that leaves resource unconstrained applies to every queue. This is how an “all queues” grant is written, and it is what every durable-queue grant written for the deployment-wide checks already looks like.

Configuration#

Variable

Helm value

Description

OMNI_EVENTS_PER_QUEUE_PERMISSIONS_ENABLED

permissions.per_queue.enabled

Name the queue in every durable-queue check (true), or name no resource (false, the default). Requires OMNI_EVENTS_PERMISSIONS_ENABLED (Helm permissions.enabled) to be true; otherwise the service refuses to start and the chart refuses to render.

Turning It On#

  1. Register DurableQueue and EventResourceId as resource types of event-consumer-service in the permission service. This step is required. The service names DurableQueue in per-queue checks and EventResourceId in fine-grained event checks. Register both, and keep them registered together: when only some of a service’s resource types are registered, a permission service that stores its metadata in a database can identify callers by a different principal claim on checks for the unregistered ones, so existing grants for them stop matching. Leave both at the default evaluation priority, forbid, so that a forbid policy always wins over a permit in the same order group. That is also how the permission service treats them while they are unregistered, so registering them changes no decision by itself. In a permission service whose service metadata is seeded from its Helm values, add both types under the event-consumer-service entry’s resourceTypes. Keep everything the entry already has: its actions, and its principal.idClaim if it sets one.

    database:
      init:
        services:
          - name: "event-consumer-service"
            actions:
              - "consume-all-storage-create-events"
              - "consume-all-storage-delete-events"
              - "consume-durable-queues"
              - "create-durable-queues"
              - "delete-durable-queues"
              - "inspect-and-replay-all-dlq"
            resourceTypes:
              - type: "DurableQueue"
              - type: "EventResourceId"
    

    A permission service that stores its metadata in a database can register the types through its service-metadata API instead, with one PUT /v1beta/services/event-consumer-service/resource-types/<type>/ per type. Register EventResourceId first, so DurableQueue is never registered without it.

  2. Turn on the switch. Your existing durable-queue grants leave resource unconstrained, so they keep matching every queue: turning the switch on changes no decision.

  3. Add per-queue grants for every queue each principal uses. The service’s INFO log lines Durable queue created and Durable queue attached (existing) name each queue id with the principal that created or attached to it. Take consumers from your clients’ own configuration, where each client’s queue_id is set; the service’s logs name a consuming principal only in some cases, so they do not list every consumer.

  4. Narrow or remove the broad grants of the three durable-queue actions only (see What To Know Before Narrowing Grants). Isolation starts here: until the unconstrained grants are gone, they still cover every queue.

Policy Examples#

// Service A may create, update and consume from its one queue.
permit(
  principal == Principal::"service-a",
  action in [
    Action::"event-consumer-service:create-durable-queues",
    Action::"event-consumer-service:consume-durable-queues"
  ],
  resource == DurableQueue::"service-a-events"
);
// Service B may use two queues: one policy per queue.
permit(
  principal == Principal::"service-b",
  action in [
    Action::"event-consumer-service:create-durable-queues",
    Action::"event-consumer-service:consume-durable-queues"
  ],
  resource == DurableQueue::"service-b-events"
);
permit(
  principal == Principal::"service-b",
  action in [
    Action::"event-consumer-service:create-durable-queues",
    Action::"event-consumer-service:consume-durable-queues"
  ],
  resource == DurableQueue::"service-b-backfill"
);
// Administrators may delete any durable queue: resource is left unconstrained.
permit(
  principal in Group::"queue-admins",
  action == Action::"event-consumer-service:delete-durable-queues",
  resource
);
// Service C may use every queue whose id starts with "service-c-".
permit(
  principal == Principal::"service-c",
  action in [
    Action::"event-consumer-service:create-durable-queues",
    Action::"event-consumer-service:consume-durable-queues"
  ],
  resource
)
when { resource.id like "service-c-*" };

End a prefix with a delimiter, as "service-c-*" does: like "service-c*" would also match a queue named service-cache. A group in the policy head (principal in Group::"<group>") grants a queue to every member of the group.

What To Know Before Narrowing Grants#

  • Server-assigned ids. A create that omits queue_id is checked against the id the service assigns, which no per-queue grant can name in advance. Only a principal whose create grant covers every queue can create such queues; a principal whose grants name its queues supplies its own queue_id.

  • Sharing a queue. Two principals granted the same queue share it, which is how a queue is shared on purpose. A create naming a queue the caller is not granted is refused with PERMISSION_DENIED whether or not the queue exists.

  • A missing consume grant can lose messages. A consumer that a too-narrow grant refuses cannot attach to its queue, and a durable queue that goes without a consumer for the deployment’s idle window (24 hours by default) is deleted along with its messages. Add the consume grants before you narrow the broad ones.

  • Changes take time to reach every pod. Each service pod caches both allowed and refused decisions for OMNI_EVENTS_PERMISSIONS_TTL_SECONDS (600 seconds by default), so a new grant or a revocation takes up to that long to apply. A consume stream that is already open is not checked again; it keeps running, and each event on it is still checked against the consumer’s event permissions.

  • Handing a queue over. Moving a queue to another principal, or following an identity-provider migration, means editing its policies; the queue and its messages are untouched.

  • Deleted queues keep their policies. A policy that names a queue stays in the permission service after the queue is deleted, and it also covers a new queue created later with the same id.

  • Turning it off again. Turning per-queue authorization off, or moving to a release without it, removes the resource from every check. Policies that depend on the resource (resource == DurableQueue::"…", resource is DurableQueue, or a condition on resource) then stop matching, so a principal that holds only per-queue grants is refused every durable-queue operation until it has a grant that leaves resource unconstrained. Per-queue forbid policies stop applying the same way, so a grant that leaves resource unconstrained reaches the queues they protected again.

  • Dead-letter access stays deployment-wide. inspect-and-replay-all-dlq names no queue in either mode. Per-event authorization of the events on a queue works the same in both modes.

  • Event permissions stay as they are. The storage-event actions (consume-all-storage-create-events, consume-all-storage-delete-events) are checked with no resource in both modes, and configured event permissions are checked on EventResourceId. A grant of them limited to a durable queue never matches, so the stream’s events of those types are refused and removed from the queue. Narrow only the three durable-queue actions.

  • Delegating policy editing. The permission service’s own permissions:edit action can be granted on DurableQueue::"<queue_id>", which lets a team manage the policies that name its own queue.

Durable Queue Event Permissions#

When consuming from a durable queue, additional permissions checks are performed on individual events based on their event type.

For storage events, the service checks:

  • omni.storage.created (file created) → Action: consume-all-storage-create-events

  • omni.storage.dir_created (directory created) → Action: consume-all-storage-create-events

  • omni.storage.deleted (file deleted) → Action: consume-all-storage-delete-events

  • omni.storage.dir_deleted (directory deleted) → Action: consume-all-storage-delete-events

Policy Example:

// Allow thumbnail service to consume all file and directory creation events
permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:consume-all-storage-create-events",
  resource
);

For non-storage events with configured permissions (see the Fine-Grained Permissions for Arbitrary Event Types section below), the action and resource from the configuration are used.

An event on a durable queue that the consuming principal is not authorized for is removed from the queue without being delivered, and granting the permission later does not bring it back. Grant the event permissions before the principal starts consuming.

Non-Durable Queue Permissions#

Non-durable queues don’t require special queue management permissions, but every event is checked for permissions before being delivered.

This is where the sophisticated permission models come into play:

  1. Storage events: Use storage-API-based “docs” permissions

  2. Configured event types: Use permissions API with configured actions

  3. Unconfigured event types: No permissions check (allowed by default)

Storage Events: Special “Docs” Permissions#

Storage events have special handling because they relate to files and directories that have their own access control in the Storage API.

Storage Event Types#

The following event types are treated as storage events:

  • omni.storage.created - File created

  • omni.storage.deleted - File deleted

  • omni.storage.dir_created - Directory created

  • omni.storage.dir_deleted - Directory deleted

How Storage Permissions Work#

For non-durable queues, the Event Consumer Service queries the Storage API to check if the user has access to the files/directories in the events.

This is sometimes called “docs” permissions because it’s based on the actual documents/files the user can access in storage.

For omni.storage.created Events#

When a file is created:

  1. The consumer service checks if the user has access to the parent directory

  2. If they have any files in that directory (i.e., they can “see” the directory), they receive the event

  3. The file is added to a cache so that if it’s deleted later, the service knows they should receive the delete event

For omni.storage.deleted Events#

When a file is deleted:

  1. The service checks if the user had access to that file (from the cache or by checking the parent directory)

  2. If they had access, they receive the delete event

  3. The file is removed from the cache

For omni.storage.dir_created Events#

When a directory is created:

  1. The service checks if the user has access to the parent directory

  2. It queries the Storage API to see if they have any files in the new directory

  3. If they have files in the new directory, they receive the event

  4. The directory is added to the cache

For omni.storage.dir_deleted Events#

When a directory is deleted:

  1. The service checks if the user had access to the directory (from the cache)

  2. It checks if they still have files in that “deleted” directory (in case it’s a synthetic delete event)

  3. If the directory was truly deleted for them, they receive the event

  4. The directory is removed from the cache

Why “Docs” Permissions?#

This approach ensures that:

  • Users only see events for files they have access to

  • A user creating a file in a shared folder only sees that event if they can access the folder

  • Delete events are only sent if the user could see the file/directory in the first place

  • Permissions are enforced at the storage layer, not duplicated in the events system

Configuration for Storage Events#

The Event Consumer Service needs to know which Storage API endpoints to query:

Environment Variable: OMNI_EVENTS_PERMISSIONS_YAML_FILE_PATH

YAML Configuration File:

storage_permissions_endpoints:
  - https://storage-api-1.example.com
  - https://storage-api-2.example.com

Example:

OMNI_EVENTS_PERMISSIONS_YAML_FILE_PATH=/etc/event-consumer/permissions.yml

In the YAML file (/etc/event-consumer/permissions.yml):

storage_permissions_endpoints:
  - https://azure-blob-storage-api.example.com
  - https://s3-storage-api.example.com

Currently, the first endpoint in the list is used. In the future, routing rules will determine which endpoint to use based on the event’s resource_id.

Storage Permissions with Durable Queues#

Important difference: When using durable queues, storage events don’t use the Storage API “docs” permissions.

Instead, they use simple action-based permissions:

  • omni.storage.created and omni.storage.dir_created → consume-all-storage-create-events

  • omni.storage.deleted and omni.storage.dir_deleted → consume-all-storage-delete-events

Why? Durable queues are typically used by services (not end users) that need to process all storage events, not just those for files they own.

Policy Example:

// Thumbnail service can consume all file creation events
permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:consume-all-storage-create-events",
  resource
);

// Audit service can consume all file deletion events
permit(
  principal == Principal::"audit-service",
  action == Action::"event-consumer-service:consume-all-storage-delete-events",
  resource
);

Fine-Grained Permissions for Arbitrary Event Types#

For non-storage event types, you can configure fine-grained permissions that check not just the event type, but also the resource_id from the event.

When to Use Fine-Grained Permissions#

Use fine-grained permissions when:

  • Different users should see different events of the same type

  • Events relate to resources with their own access control (like files, projects, workspaces)

  • You need to enforce permissions based on the event’s resource_id

Examples:

  • A user should only see project.updated events for projects they have access to

  • A user should only see thumbnail.generated events for their own files

  • A user should only see notification.created events for notifications sent to them

Configuration#

Fine-grained permissions are configured via a YAML file specified by the OMNI_EVENTS_PERMISSIONS_YAML_FILE_PATH environment variable.

Environment Variable:

OMNI_EVENTS_PERMISSIONS_YAML_FILE_PATH=/etc/event-consumer/permissions.yml

YAML Configuration File:

event_permissions:
  - event_type: project.updated
    action: read-project
  - event_type: thumbnail.generated
    action: read-thumbnail
  - event_type: notification.created
    action: read-notification

How It Works#

For each event of a configured type:

  1. The Event Consumer Service makes a permissions API call

  2. It uses the configured action

  3. It uses the event’s resource_id as the resource ID

  4. The resource type is always EventResourceId

  5. If the principal has permission, the event is delivered; otherwise, it’s filtered out

Permissions API Call#

When consuming an event like:

{
  "event_type": "project.updated",
  "resource_id": "/projects/project-123",
  "message": {}
}

The service makes this permissions check:

  • Principal: The authenticated user

  • Action: read-project (from config)

  • Resource Type: EventResourceId

  • Resource ID: /projects/project-123 (from event)

Policy Examples#

Scoped Policy (using resource_id):

// Allow user to see events for project-123
permit(
  principal == Principal::"user@example.com",
  action == Action::"event-consumer-service:read-project",
  resource == EventResourceId::"/projects/project-123"
);

Global Policy (ignoring resource_id):

// Allow admin to see all project events
permit(
  principal == Principal::"admin@example.com",
  action == Action::"event-consumer-service:read-project",
  resource
);

Hierarchical Policy (a resource_id prefix):

// Allow user to see events for every project under /projects/team-a/
permit(
  principal == Principal::"user@example.com",
  action == Action::"event-consumer-service:read-project",
  resource is EventResourceId
)
when {
  resource.id like "/projects/team-a/*"
};

The resource the service sends carries only its type and its id (the event’s resource_id), so conditions can read resource.id and resource.type and nothing else.

Complete Configuration Example#

YAML file (/etc/event-consumer/permissions.yml):

# Storage API endpoints for "docs" permissions
storage_permissions_endpoints:
  - https://storage-api.example.com

# Fine-grained event permissions
event_permissions:
  # Project events
  - event_type: project.created
    action: read-project
  - event_type: project.updated
    action: read-project
  - event_type: project.deleted
    action: read-project
  
  # Thumbnail events
  - event_type: thumbnail.generated
    action: read-thumbnail
  
  # Notification events  
  - event_type: notification.created
    action: read-notification
  
  # Workflow events
  - event_type: workflow.completed
    action: read-workflow

Corresponding Policies:

// User can see events for the projects under their team's folder
permit(
  principal == Principal::"user@example.com",
  action in [
    Action::"event-consumer-service:read-project"
  ],
  resource is EventResourceId
)
when {
  resource.id like "/projects/team-a/*"
};

// User can see thumbnails for the files under their own folder
permit(
  principal == Principal::"user@example.com",
  action == Action::"event-consumer-service:read-thumbnail",
  resource is EventResourceId
)
when {
  resource.id like "/users/user@example.com/*"
};

// Members of the support group can see support notification events
permit(
  principal in Group::"support",
  action == Action::"event-consumer-service:read-notification",
  resource is EventResourceId
)
when {
  resource.id like "/notifications/support/*"
};

Permissions Decision Flow#

Here’s how permissions are checked when consuming events:

Non-Durable Queues#

┌─────────────────────────────────────────┐
│ Event Received                          │
└────────────────┬────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────┐
│ Is it a storage event?                  │
│ (the four omni.storage.* event types)   │
└────────┬─────────────────────┬──────────┘
         │ Yes                 │ No
         ▼                     ▼
┌────────────────────┐  ┌──────────────────────────┐
│ Check Storage API  │  │ Is event_type configured │
│ "docs" permissions │  │ in permissions.yml?      │
└────────┬───────────┘  └──────┬──────────────┬────┘
         │                     │ Yes          │ No
         │                     ▼              ▼
         │            ┌────────────────┐  ┌────────────┐
         │            │ Check          │  │ Allow      │
         │            │ permissions    │  │ (no check) │
         │            │ API with       │  └────────────┘
         │            │ configured     │
         │            │ action +       │
         │            │ resource_id    │
         │            └────────┬───────┘
         │                     │
         ▼                     ▼
┌─────────────────────────────────────────┐
│ Allow or Deny?                          │
└────────┬─────────────────────┬──────────┘
         │ Allow               │ Deny
         ▼                     ▼
┌────────────────┐    ┌───────────────────┐
│ Deliver Event  │    │ Filter Out Event  │
└────────────────┘    └───────────────────┘

Durable Queues#

┌─────────────────────────────────────────┐
│ Event Received                          │
└────────────────┬────────────────────────┘
                 │
                 ▼
┌─────────────────────────────────────────┐
│ Is it a storage event?                  │
└────────┬─────────────────────┬──────────┘
         │ Yes                 │ No
         ▼                     ▼
┌────────────────────┐  ┌──────────────────────────┐
│ Check:             │  │ Is event_type configured │
│ - omni.storage.    │  │ in permissions.yml?      │
│   created and      │  └──────┬──────────────┬────┘
│   dir_created      │         │ Yes          │ No
│   → consume-all-   │         ▼              ▼
│     storage-create │  ┌────────────────┐ ┌───────┐
│     -events        │  │ Check perms    │ │ Allow │
│ - omni.storage.    │  │ API with       │ └───────┘
│   deleted and      │  │ action +       │
│   dir_deleted      │  │ resource_id    │
│   → consume-all-   │  └────────┬───────┘
│     storage-delete │           │
│     -events        │           │
└────────┬───────────┘           │
         │                       │
         ▼                       ▼
┌─────────────────────────────────────────┐
│ Allow or Deny?                          │
└────────┬─────────────────────┬──────────┘
         │ Allow               │ Deny
         ▼                     ▼
┌────────────────┐    ┌───────────────────┐
│ Deliver Event  │    │ Filter Out Event  │
└────────────────┘    └───────────────────┘

Summary Table#

Scenario

Queue Type

Event Type

Permission Check

Create queue (or attach to an existing one)

Durable

N/A

create-durable-queues; with per-queue authorization, on DurableQueue::"<queue_id>"

Update queue filters

Durable

N/A

create-durable-queues; with per-queue authorization, on DurableQueue::"<queue_id>"

Delete queue

Durable

N/A

delete-durable-queues; with per-queue authorization, on DurableQueue::"<queue_id>"

Consume from queue

Durable

N/A

consume-durable-queues; with per-queue authorization, on DurableQueue::"<queue_id>"

Inspect and replay dead-lettered messages

Durable

N/A

inspect-and-replay-all-dlq (deployment-wide in both modes)

Storage create event

Durable

omni.storage.created, omni.storage.dir_created

consume-all-storage-create-events

Storage delete event

Durable

omni.storage.deleted, omni.storage.dir_deleted

consume-all-storage-delete-events

Storage event

Non-durable

The four omni.storage.* event types

Storage API “docs” permissions

Configured event

Either

Any

Configured action + EventResourceId

Unconfigured event

Either

Any

No check (allowed)

Publishing

N/A

Any

publish-event with EventType

Configuration Examples#

Example 1: Thumbnail Service (Durable Queue)#

The thumbnail service needs to process all file creation events.

Permissions Policies:

permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:create-durable-queues",
  resource
);

permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:consume-durable-queues",
  resource
);

permit(
  principal == Principal::"thumbnail-service",
  action == Action::"event-consumer-service:consume-all-storage-create-events",
  resource
);

No YAML configuration needed - the service uses simple action-based permissions.

The first two policies cover every durable queue. Where the deployment enforces per-queue authorization, limit them to the service’s own queue by replacing resource with resource == DurableQueue::"thumbnail-uploads" (the service’s queue_id). Leave the third policy as it is: a storage-event grant limited to a queue never matches (see What To Know Before Narrowing Grants).

Example 2: User Dashboard (Non-Durable Queue)#

A web dashboard shows users file events for files they have access to.

Permissions Policies:

// No special policies needed - users use their existing storage permissions
// The Event Consumer Service will query the Storage API to check access

YAML Configuration (/etc/event-consumer/permissions.yml):

storage_permissions_endpoints:
  - https://storage-api.example.com

Example 3: Project Notification Service (Non-Durable + Fine-Grained)#

A service that shows users events for projects they have access to.

Permissions Policies:

// Members of a project's group see that project's events
permit(
  principal in Group::"project-123-members",
  action in [
    Action::"event-consumer-service:read-project"
  ],
  resource == EventResourceId::"/projects/project-123"
);

YAML Configuration:

event_permissions:
  - event_type: project.created
    action: read-project
  - event_type: project.updated
    action: read-project
  - event_type: project.deleted
    action: read-project
  - event_type: project.member.added
    action: read-project

Best Practices#

1. Use Service Accounts for Durable Queues#

Create dedicated service principals for services using durable queues:

permit(
  principal == Principal::"service:thumbnail-generator",
  action == Action::"event-consumer-service:create-durable-queues",
  resource
);

By default the durable-queue actions are deployment-wide, so anyone granted them can reach any durable queue whose queue_id they know. Keep the grants on named service principals rather than on broad user groups, and give each service a namespaced queue_id (<service-name>-<purpose>) so queues stay attributable. To limit each service to its own queues, turn on per-queue authorization and grant each service its queues by id or by id prefix.

2. Document Your Permission Requirements#

When defining new event types, document what permissions are needed:

"""
Event Type: project.updated

Publishing Permission:
  - Action: publish-event
  - Resource: EventType::"project.updated"

Consuming Permission (fine-grained):
  - Action: read-project
  - Resource: EventResourceId (project's resource_id)
  
Example policy:
  permit(
    principal in Group::"project-123-members",
    action == Action::"event-consumer-service:read-project",
    resource == EventResourceId::"/projects/project-123"
  );
"""

3. Test Permissions Thoroughly#

Test both positive and negative cases:

  • Can authorized users publish/consume?

  • Are unauthorized users properly denied?

  • Do permissions work correctly after token refresh?

  • Are fine-grained permissions enforced correctly?

4. Monitor Permission Denials#

Log and monitor permission denials to detect:

  • Misconfigured policies

  • Users attempting unauthorized access

  • Services missing required permissions

5. Use Hierarchical Policies Where Appropriate#

For resource hierarchies, use hierarchical policies:

// Allow user to see events for anything in their team's workspace
permit(
  principal == Principal::"user@example.com",
  action == Action::"event-consumer-service:read-file",
  resource is EventResourceId
)
when {
  resource.id like "/workspaces/team-a/*"
};

Troubleshooting#

“Permission denied” when publishing#

Check:

  1. Is OMNI_EVENTS_PERMISSIONS_ENDPOINT configured?

  2. Does your principal have publish-event permission for the EventType?

  3. Is your auth token valid and not expired?

“Permission denied” when creating durable queue#

Check:

  1. Does your principal have create-durable-queues permission?

  2. If the deployment enforces per-queue authorization, does a policy grant create-durable-queues on DurableQueue::"<queue_id>", or on every queue? A create that omits queue_id needs a grant on every queue.

  3. Is your auth token valid?

Not receiving storage events (non-durable)#

Check:

  1. Is storage_permissions_endpoints configured correctly in the YAML file?

  2. Does the Storage API show that the user has access to files in the relevant directories?

  3. Do your filters name the storage event types exactly as listed in Storage Event Types?

Not receiving events with fine-grained permissions#

Check:

  1. Is the event_type configured in the event_permissions section of the YAML file?

  2. Does your policy grant the configured action for the resource_id in the events?

  3. Is the resource_id in the events formatted as expected?

Token expiring during stream#

Solution: Implement automatic token refresh and reconnection. See Consuming Events - Error Handling.

Next Steps#