Permissions & Roles¶
Revel implements a layered permission system built on three distinct concepts: organization ownership, staff memberships with granular permissions, and member memberships with statuses. Permissions are enforced at the API layer via Django Ninja Extra permission classes that use the has_object_permission pattern.
Admin panel groups are separate
This page covers API-layer permissions only. The Django admin panel (/admin/) uses Django's built-in auth.Group system, which is independent of OrganizationStaff.permissions. See the Admin groups runbook for the Sales group policy and the sync_sales_group management command.
Permission Hierarchy¶
flowchart TD
subgraph Auth["Authentication Layer"]
JWT["I18nJWTAuth<br/>(required)"]
OptAuth["OptionalAuth<br/>(anonymous allowed)"]
end
subgraph OrgRoles["Organization Roles (3 separate concepts)"]
Owner["Organization.owner<br/><i>FK to RevelUser</i><br/>Full control, implicit all permissions"]
Staff["OrganizationStaff<br/><i>through model</i><br/>JSON permissions field"]
Member["OrganizationMember<br/><i>through model</i><br/>status + tier"]
end
subgraph PermClasses["Permission Classes (RootPermission subclasses)"]
OrgPerm["OrganizationPermission(action)"]
EventPerm["EventPermission(action)"]
SeriesPerm["EventSeriesPermission(action)"]
QuestPerm["QuestionnairePermission(action)"]
OwnerOnly["IsOrganizationOwner()"]
StaffOnly["IsOrganizationStaff()"]
TicketPerm["CanPurchaseTicket()"]
end
subgraph Resolution["Permission Resolution"]
CheckOwner{"Is user the<br/>Organization.owner?"}
CheckStaff{"Is user in<br/>OrganizationStaff?"}
CheckPerm{"staff.has_permission<br/>(action, event_id)?"}
Granted["Granted"]
Denied["Denied (403)"]
end
JWT --> PermClasses
OptAuth -->|"no permission classes"| Granted
OrgPerm --> CheckOwner
EventPerm --> CheckOwner
SeriesPerm --> CheckOwner
QuestPerm --> CheckOwner
CheckOwner -->|"Yes"| Granted
CheckOwner -->|"No"| CheckStaff
CheckStaff -->|"Yes"| CheckPerm
CheckStaff -->|"No"| Denied
CheckPerm -->|"True"| Granted
CheckPerm -->|"False"| Denied Organization Roles¶
Revel does not use a single role field. Instead, a user's relationship with an organization is determined by three independent models:
1. Organization Owner (Organization.owner)¶
A direct ForeignKey from Organization to RevelUser. There is exactly one owner per organization.
class Organization(TimeStampedModel, ...):
owner = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="owned_organizations",
)
Owner privileges
The owner implicitly bypasses all permission checks. Every permission class checks organization.owner_id == request.user.id first and returns True immediately if it matches.
2. Organization Staff (OrganizationStaff)¶
A through model for the Organization.staff_members M2M field. Each staff record carries a JSON permissions field validated by PermissionsSchema.
class OrganizationStaff(TimeStampedModel):
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
permissions = models.JSONField(
default=_get_default_permissions,
validators=[_validate_permissions],
)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["organization", "user"],
name="unique_organization_staff",
)
]
3. Organization Member (OrganizationMember)¶
A through model for the Organization.members M2M field. Members have a status and an optional tier.
class OrganizationMember(TimeStampedModel):
class MembershipStatus(models.TextChoices):
ACTIVE = "active"
PAUSED = "paused"
CANCELLED = "cancelled"
BANNED = "banned"
organization = models.ForeignKey(Organization, on_delete=models.CASCADE)
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
status = models.CharField(
default=MembershipStatus.ACTIVE,
choices=MembershipStatus.choices,
)
tier = models.ForeignKey(MembershipTier, on_delete=models.SET_NULL, null=True)
| Status | Visibility | Ticket Purchase | Notes |
|---|---|---|---|
| ACTIVE | Can see members-only/private orgs | Yes (if tier matches) | Full membership |
| PAUSED | Can see members-only/private orgs | Depends on context | Temporarily inactive |
| CANCELLED | Treated as no membership | No | As if the record doesn't exist |
| BANNED | Cannot see any orgs (even public) | No | Total exclusion |
Members have no management permissions
OrganizationMember does not carry any permission flags. Members cannot manage events, tickets, or organization settings. Membership only controls visibility and ticket purchase eligibility.
Staff Permissions (PermissionMap and PermissionsSchema)¶
Staff permissions are stored as structured JSON on OrganizationStaff.permissions, validated by two Pydantic models defined in events/models/organization.py:
PermissionMap¶
A flat set of boolean flags representing individual actions:
| Permission | Default | Description |
|---|---|---|
view_organization_details | True | View organization admin details |
create_event | False | Create new events |
create_event_series | False | Create event series |
edit_event_series | False | Edit event series |
delete_event_series | False | Delete event series |
edit_event | True | Edit existing events |
delete_event | False | Delete events |
open_event | True | Open an event for registration |
manage_tickets | True | Manage ticket tiers and settings |
close_event | True | Close event registration |
manage_event | True | General event management |
check_in_attendees | True | Check in ticket holders |
invite_to_event | True | Send event invitations |
edit_organization | False | Edit organization settings |
manage_members | False | Manage members and membership requests |
manage_potluck | False | Manage potluck items |
create_questionnaire | False | Create questionnaires |
edit_questionnaire | False | Edit questionnaires |
delete_questionnaire | False | Delete questionnaires |
evaluate_questionnaire | True | Review and score questionnaire submissions |
send_announcements | False | Send announcements to members |
manage_subscriptions | True | Manage membership subscriptions |
manage_polls | False | Manage organization polls |
PermissionsSchema (with per-event overrides)¶
Wraps PermissionMap with support for per-event overrides:
class PermissionsSchema(BaseModel):
model_config = ConfigDict(extra="forbid")
default: PermissionMap = Field(default_factory=PermissionMap)
event_overrides: dict[uuid.UUID, PermissionMap] = Field(default_factory=dict)
The JSON stored on OrganizationStaff.permissions looks like:
{
"default": {
"create_event": false,
"edit_event": true,
"check_in_attendees": true,
...
},
"event_overrides": {
"550e8400-e29b-41d4-a716-446655440000": {
"edit_event": false,
"check_in_attendees": true,
...
}
}
}
Per-event override resolution
When checking a permission, OrganizationStaff.has_permission(permission, event_id) first looks in event_overrides[event_id]. If found, it uses that map. Otherwise, it falls back to default.
def has_permission(self, permission: str, event_id: str | None = None) -> bool:
if event_id:
event_permissions = self.permissions.get("event_overrides", {}).get(event_id, {})
if event_permissions:
return bool(event_permissions.get(permission, False))
return bool(self.permissions.get("default", {}).get(permission, False))
Permission Classes¶
All permission classes live in events/controllers/permissions.py and inherit from RootPermission, which itself extends Django Ninja Extra's BasePermission.
RootPermission (base class)¶
class RootPermission(BasePermission):
def __init__(self, action: str) -> None:
self.action = action
def has_permission(self, request, controller) -> bool:
return True # Always passes; actual check is in has_object_permission
Why has_permission always returns True
Django Ninja Extra calls has_permission first (request-level), then has_object_permission (object-level). The actual authorization logic lives entirely in has_object_permission, which receives the resolved object from get_object_or_exception.
Permission Class Reference¶
| Class | action parameter | Object type | Description |
|---|---|---|---|
OrganizationPermission(action) | Any PermissionMap field | Organization | Checks owner or staff permission on the org |
EventPermission(action) | Any PermissionMap field | Event | Checks owner or staff permission via event.organization |
EventSeriesPermission(action) | Any PermissionMap field | EventSeries | Checks owner or staff permission via series.organization |
QuestionnairePermission(action) | Any PermissionMap field | OrganizationQuestionnaire | Delegates to OrganizationPermission via questionnaire.organization |
IsOrganizationOwner() | "is_owner" (ignored) | Organization | Owner-only; raises PermissionDenied for non-owners |
IsOrganizationStaff() | "is_staff" (ignored) | Organization | Owner or any staff member (no specific permission needed) |
CanDuplicateEvent() | "create_event" | Event | Checks create_event via the event's organization |
ManagePotluckPermission() | "manage_potluck" | PotluckItem | Item creator, org owner, or staff with manage_potluck |
PotluckItemPermission(action) | Configurable | Event | Owner, staff, or attendee (with special potluck_open check) |
CanPurchaseTicket() | "can_purchase" | TicketTier | Checks sale window + purchasable_by eligibility |
Common Permission Check Pattern¶
Every action-based permission class follows the same resolution order:
class OrganizationPermission(RootPermission):
def has_object_permission(self, request, controller, obj: Organization) -> bool:
# 1. Owner always passes
if obj.owner_id == request.user.id:
return True
# 2. Look up staff membership
if staff_member := OrganizationStaff.objects.filter(
organization=obj,
user_id=request.user.id,
).first():
# 3. Check specific permission flag
return staff_member.has_permission(self.action)
# 4. Not owner, not staff -> denied
return False
Usage in Controllers¶
Permissions are wired to endpoints via the permissions= parameter on either the @api_controller decorator (class-level default) or individual @route.* decorators (per-endpoint override).
Class-Level Permissions¶
When all endpoints in a controller share the same permission, set it at the class level:
@api_controller(
"/event-series-admin/{series_id}",
auth=I18nJWTAuth(),
permissions=[EventSeriesPermission("edit_event_series")],
tags=["Event Series Admin"],
throttle=WriteThrottle(),
)
class EventSeriesAdminController(UserAwareController): ...
Per-Endpoint Permissions¶
Override permissions on specific endpoints when they require different access levels:
@api_controller(
"/organization-admin/{slug}",
auth=I18nJWTAuth(),
tags=["Organization Admin"],
throttle=WriteThrottle(),
)
class OrganizationAdminCoreController(OrganizationAdminBaseController):
@route.get(
"",
permissions=[IsOrganizationStaff()], # Any staff can view
throttle=UserDefaultThrottle(),
)
def get_organization(self, slug: str) -> models.Organization:
return self.get_one(slug)
@route.put(
"",
permissions=[OrganizationPermission("edit_organization")], # Needs specific permission
)
def update_organization(self, slug: str, payload: schema.OrganizationEditSchema):
organization = self.get_one(slug)
return update_db_instance(organization, payload)
@route.post(
"/stripe/connect",
permissions=[IsOrganizationOwner()], # Owner only
)
def stripe_connect(self, slug: str, payload: EmailSchema): ...
Owner-only fields inside edit_organization-gated endpoints
A few financially sensitive organization fields are owner-only even when the endpoint itself is gated by edit_organization (which is staff-grantable). The update flows past the permission class, but the service layer re-checks ownership and rejects the change for non-owners.
The current example is revenue_report_cadence (it controls scheduled financial-report delivery, consistent with the owner-only org revenue endpoints). A non-owner staff member with edit_organization can update other org fields, but attempting to change revenue_report_cadence raises RevenueReportCadenceOwnerOnlyError. The check lives in update_organization (events/service/organization_service.py), not in the permission class (carve-out introduced in v1.65.0).
How get_object_or_exception Triggers Permission Checks¶
The get_object_or_exception method (inherited from ControllerBase) fetches the object from the queryset and then calls check_object_permissions against the controller's permission classes. This is why permission classes receive the resolved object in has_object_permission.
class EventAdminBaseController(UserAwareController):
def get_queryset(self) -> QuerySet[models.Event]:
return models.Event.objects.for_user(self.user(), include_past=True)
def get_one(self, event_id: UUID) -> models.Event:
# Fetches object AND runs has_object_permission for all permission classes
return t.cast(
models.Event,
self.get_object_or_exception(self.get_queryset(), pk=event_id),
)
sequenceDiagram
actor User
participant Controller
participant Auth as I18nJWTAuth
participant QS as get_queryset()
participant Perm as has_object_permission()
User->>Controller: Request with JWT
Controller->>Auth: Validate token
Auth-->>Controller: RevelUser
Controller->>QS: get_object_or_exception(qs, pk=id)
QS-->>Controller: Object (or 404)
Controller->>Perm: check_object_permissions(object)
alt Organization.owner
Perm-->>Controller: True (immediate)
else OrganizationStaff with permission
Perm-->>Controller: staff.has_permission(action) -> True
else OrganizationStaff without permission
Perm-->>Controller: False -> PermissionDenied (403)
else Not staff
Perm-->>Controller: False -> PermissionDenied (403)
end
Controller->>Controller: Execute endpoint logic
Controller-->>User: Response Authentication¶
I18nJWTAuth¶
The default authentication class. Requires a valid JWT access token in the Authorization header. Activates the authenticated user's preferred language for the request.
@api_controller("/events", auth=I18nJWTAuth(), throttle=UserDefaultThrottle())
class EventAdminController(UserAwareController): ...
OptionalAuth¶
Extends I18nJWTAuth for endpoints that work with or without authentication. If no token is present, sets request.user to AnonymousUser and continues.
@api_controller("/event-series", auth=OptionalAuth(), tags=["Event Series"])
class EventSeriesController(UserAwareController):
def list_event_series(self, ...):
# self.maybe_user() returns RevelUser or AnonymousUser
...
Use maybe_user() with OptionalAuth
When using OptionalAuth, call self.maybe_user() (returns RevelUser | AnonymousUser) instead of self.user() (which casts to RevelUser unconditionally). Both are defined on UserAwareController.
Ticket Purchase Eligibility¶
CanPurchaseTicket is a specialized permission class that checks both the sale window and audience restrictions on a TicketTier:
TicketTier.PurchasableBy | Who can purchase |
|---|---|
PUBLIC | Anyone |
MEMBERS | Organization members only |
INVITED | Event invitees only |
INVITED_AND_MEMBERS | Members or invitees |
The check order: sale window -> org owner -> org staff -> membership/invitation check.
Membership status not filtered
The membership check currently queries OrganizationMember without filtering by status=ACTIVE. This means paused or cancelled members may still pass the check. This is a known limitation.
Organization Visibility¶
Organizations use a Visibility enum (from VisibilityMixin) that controls who can discover them:
| Visibility | Who can see |
|---|---|
PUBLIC | Everyone (except banned/blacklisted users) |
MEMBERS_ONLY | Active/paused members, staff, and owner |
PRIVATE | Active/paused members, staff, and owner |
STAFF_ONLY | Staff and owner only |
See also
The Eligibility Pipeline documents how event-level access controls (questionnaires, max attendees, RSVP deadlines) are evaluated in sequence.