Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions docs/permissions.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@

# Permissions & Data Model

## Core Data Model
Expand All @@ -17,11 +18,14 @@ User
│ │ │ ├── blob → Blob (raw MIME)
│ │ │ ├── draft_blob → Blob (JSON draft content)
│ │ │ └── attachments → Attachment → Blob (only for drafts)
│ │ ├── events → ThreadEvent (im / assign / unassign)
│ │ │ └── user_events → UserEvent (mention / assign)
│ │ ├── accesses → ThreadAccess (multiple mailboxes)
│ │ └── labels → Label (M2M)
│ ├── contacts → Contact
│ ├── labels → Label
│ └── blobs → Blob
├── user_events → UserEvent (per-user notifications, thread-scoped)
└── MailDomainAccess (role: ADMIN)
└── MailDomain
└── mailboxes → Mailbox (via domain FK)
Expand All @@ -37,6 +41,8 @@ User
| **Thread** | Message thread | `subject`, denormalized flags (`has_trashed`, `is_spam`, etc.) |
| **ThreadAccess** | Mailbox→Thread permission | `thread`, `mailbox`, `role` (unique together) |
| **Message** | Email message | `thread`, `sender`, `parent`, flags (`is_draft`, `is_trashed`, etc.) |
| **ThreadEvent** | Timeline entry on a thread (comment, assign, unassign) | `thread`, `type`, `author`, `data` (JSON, schema-validated per type) |
| **UserEvent** | Per-user notification derived from a ThreadEvent | `user`, `thread`, `thread_event`, `type`, `read_at` |
| **Contact** | Email address entity | `email`, `mailbox`, `name` |
| **Label** | Folder/tag (hierarchical) | `name`, `slug`, `mailbox`, `threads` (M2M) |

Expand All @@ -58,16 +64,57 @@ Role groups defined in `enums.py`:
### ThreadAccessRoleChoices (Mailbox access to Thread)

```python
VIEWER = 1 # Read-only: view thread messages
EDITOR = 2 # Edit: create replies, flag messages, manage thread sharing
VIEWER = 1 # Read-only: view thread messages and events
EDITOR = 2 # Edit: create replies, flag messages, manage thread sharing, assign
```

Role group:
- `THREAD_ROLES_CAN_EDIT = [EDITOR]`

### Key Design Principles
### Event Types

```python
# ThreadEvent.type (stored on the thread timeline)
IM = "im" # Internal comment, may embed mentions in data
ASSIGN = "assign" # User(s) newly assigned to the thread
UNASSIGN = "unassign" # User(s) removed from the thread

# UserEvent.type (per-user notification, derived from ThreadEvent)
MENTION = "mention" # One per (user, message mention); read_at tracks ack
ASSIGN = "assign" # At most one per (user, thread); source of truth for "assigned"
```

`UserEvent` is **not** mailbox-scoped: a user reachable through several mailboxes sees the same notification everywhere.

## Permission Classes

Defined in `core/api/permissions.py`. The table below lists the main ones and the rule they enforce.

| Class | Rule |
|-------|------|
| `IsAuthenticated` | Baseline — user is logged in. |
| `IsAllowedToAccess` | Read access to a Mailbox/Thread/Message/ThreadEvent via any `MailboxAccess` → `ThreadAccess` path. |
| `HasThreadEditAccess` | Full edit rights: `ThreadAccess.role == EDITOR` **AND** `MailboxAccess.role ∈ MAILBOX_ROLES_CAN_EDIT` on the same mailbox. |
| `HasThreadCommentAccess` | Allowed to author internal comments: any `ThreadAccess` (viewer or editor) on a mailbox where the user has `MAILBOX_ROLES_CAN_EDIT`. |
| `HasThreadEventWriteAccess` | Type-aware: `im` events follow the comment rule; every other `ThreadEvent` type requires full edit rights. Update/destroy is author-only. |
| `IsAllowedToCreateMessage` | User must have `MAILBOX_ROLES_CAN_EDIT` on the sender mailbox (plus EDITOR `ThreadAccess` when replying). |
| `IsAllowedToManageThreadAccess` | Managing a `ThreadAccess` requires full edit rights on the thread. |
| `IsMailboxAdmin` / `IsMailDomainAdmin` | Admin paths for mailbox and maildomain management. |
| `HasChannelScope` | Scope check for Channel-authenticated calls; `CHANNEL_API_KEY_SCOPES_GLOBAL_ONLY` further requires `scope_level=global`. |

Shared ORM helpers (`core/models.py`):
- `ThreadAccess.objects.editable_by(user, mailbox_id=None)` — rows matching the full-edit-rights rule.
- `ThreadAccess.objects.editor_user_ids(thread_id, user_ids=None)` — user ids with full edit rights on a thread.

## Key Design Principles

1. **Two-level permission model**: User→Mailbox (MailboxAccess) and Mailbox→Thread (ThreadAccess) are independent.
2. **ThreadAccess is per-mailbox**: Each mailbox has its own access level to a thread, enabling selective sharing.
3. **Flags are shared state**: Message flags (`is_trashed`, `is_spam`, `is_unread`, etc.) are stored on the Message model directly, not per-user. Modifying them requires EDITOR ThreadAccess.
4. **Thread stats are denormalized**: Thread has boolean fields (`has_trashed`, `is_spam`, etc.) updated by `thread.update_stats()` after message flag changes.
1. **Two-level permission model.** User→Mailbox (`MailboxAccess`) and Mailbox→Thread (`ThreadAccess`) are independent and composed for every access check. Edit-level actions always verify **both** sides.
2. **ThreadAccess is per-mailbox.** Each mailbox has its own role on a given thread, enabling selective sharing and per-mailbox `read_at` / `starred_at` state.
3. **Flags are shared state.** Message flags (`is_trashed`, `is_spam`, `is_unread`, etc.) live on the Message and mutate the thread for everyone — they require EDITOR `ThreadAccess`.
4. **Thread stats are denormalized.** Thread has boolean fields (`has_trashed`, `is_spam`, …) updated by `thread.update_stats()` after message flag changes. Mention/assignment stats (`has_mention`, `has_unread_mention`, `has_assigned_to_me`, `has_unassigned`) are **not** stored: they are computed per request via `Exists(UserEvent...)` annotations in `ThreadViewSet`.
5. **Comments relax the thread role.** Posting or editing an `im` `ThreadEvent` only requires VIEWER `ThreadAccess` + mailbox edit rights. Assign/unassign and any other event type keep the stricter full-edit-rights policy.
6. **Event mutations are author-only.** Update and destroy of a `ThreadEvent` are refused for non-authors, regardless of role. A configurable window (`settings.MAX_THREAD_EVENT_EDIT_DELAY`) can close the edit/delete path entirely after creation.
7. **Assignment is derived from the event log.** `UserEvent(type=ASSIGN)` is the source of truth for "who is assigned"; there is no denormalized field on Thread. A partial `UniqueConstraint` enforces at most one active ASSIGN per `(user, thread)` and absorbs races between concurrent ASSIGN requests.
8. **Undo window for assignments.** An UNASSIGN within `UNDO_WINDOW_SECONDS` (120s) of the matching ASSIGN, by the same author, is absorbed: the original ASSIGN `ThreadEvent` is trimmed or deleted, the `UserEvent ASSIGN` is removed, and no UNASSIGN event is emitted.
9. **Access changes cascade to assignments.** Downgrading or removing a `ThreadAccess` / `MailboxAccess` triggers `cleanup_invalid_assignments`, which emits a single system `ThreadEvent(type=UNASSIGN, author=None)` for any assignee who lost full edit rights (re-evaluated across all their mailboxes).
10. **Mentions survive edits idempotently.** Editing an `im` event diffs the mentions payload and reconciles `UserEvent(MENTION)` rows; unchanged mentions keep their `read_at`, removed ones disappear from the user's "Mentioned" view, new ones are created.
Loading