Permissions
Bitmask permission flags, how they are stored, and how to protect routes.
The portal uses an IntFlag bitmask system for authorization. Permissions can be granted directly to a user or inherited from a calling the user holds.
Permission flags
Defined in backend/src/models/permissions.py as a Python IntFlag enum. Because it is an IntFlag, flags can be combined with bitwise OR.
| Flag | Value | Description |
|---|---|---|
NONE | 0 | No permissions |
MANAGE_USERS | 1 | Create, edit, and deactivate user accounts |
MANAGE_CALLINGS | 2 | Create and edit callings and slot assignments |
MANAGE_ASSIGNMENTS | 4 | Manage High Council assignments |
MANAGE_SPEAKING_SCHEDULE | 8 | Edit the speaking calendar |
SUBMIT_CALLING_PROPOSALS | 16 | Submit new calling or release proposals |
MANAGE_CALLING_PROPOSALS | 32 | Move proposals through the Kanban stages, approve/reject |
VIEW_CALLING_PROPOSALS | 64 | Read-only view of the Kanban board |
MANAGE_WARDS | 256 | Edit ward names and meeting times |
MANAGE_APPOINTMENTS | 512 | Manage appointment types, availability windows, exceptions, and bookings |
APPROVE_BLDG_RESERVATIONS | 1024 | Review, approve, and deny building reservation requests |
MANAGE_ACCESS | 2048 | Grant or revoke permissions for other users |
MANAGE_SITE_SETTINGS | 4096 | Update stake name, logo, hero image, contact info, and nav visibility |
DISCORD_BOT (value 128) is a reserved internal flag used by the Discord bot service to authenticate API calls. It cannot be assigned to portal user accounts.
How permissions are stored
The permissions table has three key columns:
| Column | Type | Meaning |
|---|---|---|
foreign_id | integer | ID of a user or a calling, depending on is_calling |
is_calling | bool | false = row grants permission to a specific user; true = row grants permission to all current holders of a calling |
scopes | integer | Bitmask of one or more Permission flags combined with bitwise OR |
User-level permissions (is_calling = false)
foreign_id is a user.id. The permission is granted to exactly that one user.
Calling-level permissions (is_calling = true)
foreign_id is a calling.id. The permission is automatically inherited by every user who currently holds that calling (i.e., has an active usercalling row for it). When a user is released from the calling, they lose the inherited permission.
Calling-level permissions are how stake presidency and high council members get their access automatically when they are assigned to the corresponding calling — no manual per-user grant is needed.
Checking permissions
user_has_permission(user_id, permission, session) in backend/src/utils/permissions.py performs the full check:
- Looks up all
permissionsrows whereforeign_id = user_idandis_calling = false. - Looks up all callings the user currently holds (via
usercalling) and checks forpermissionsrows whereforeign_idis one of those calling IDs andis_calling = true. - Combines all matched
scopesvalues with bitwise OR. - Returns
Trueif the requestedpermissionflag is set in the combined result.
Protecting a route
Pass the required flags to CallingUser via the permissions parameter. The dependency performs the check and raises HTTP 403 if the user does not hold the required flags.
from fastapi import APIRouter, Depends
from src.utils.security import CallingUser
from src.models.permissions import Permission
router = APIRouter()
@router.get("/admin-only")
def admin_route(
current_user = Depends(CallingUser(permissions=[Permission.MANAGE_USERS]))
):
return {"user_id": current_user.id}Multiple flags can be passed in the list — the user must hold all of them:
@router.post("/proposals")
def submit_proposal(
current_user = Depends(
CallingUser(permissions=[Permission.SUBMIT_CALLING_PROPOSALS])
)
):
...Passing an empty permissions list (or omitting the parameter entirely) does not mean "no permissions required" — it still requires a valid authenticated session. Use allow_anonymous=True if the endpoint should be accessible without login.
Combining flags
Because Permission is an IntFlag, you can express compound grants in a single integer:
# Grant both MANAGE_USERS and MANAGE_CALLINGS in one row
scopes = Permission.MANAGE_USERS | Permission.MANAGE_CALLINGS
# scopes is stored as an integer in the permissions.scopes columnThis is how the admin user gets all permissions: their scopes value is the bitwise OR of every flag.