Permission System¶
SYNDI uses a permission-based authorization system that separates permissions from roles/groups.
Permission Format¶
Permissions follow the format: action:resource
Examples:
submit:SOP*- Can submit any SOPview:own- Can view own entriesdraft:*- Can manage drafts*- Wildcard (all permissions)
Standard Permissions¶
Data Submission¶
submit:SOP*- Submit any SOP (Standard Operating Procedure)submit:*- Submit any entry type
Data Viewing¶
view:*- View all entriesview:own- View only own entriesview:group- View entries from own group
Draft Management¶
draft:*- Create, update, delete drafts
Approval Workflow¶
approve:*- Approve submissions
User Management¶
manage:users- Create, update, delete users*- Includes all permissions (ADMINS only)
Group-to-Permission Mapping¶
Groups are mapped to permissions via configuration files in infra/.config/lambda/:
Example (infra/.config/lambda/stage.json):
{
"lambda": {
"auth": {
"cognito": {
"groups": {
"ADMINS": {
"description": "Administrative users with full access",
"permissions": ["*"]
},
"LAB_MANAGERS": {
"description": "Lab managers with oversight and approval permissions",
"permissions": [
"submit:SOP*",
"view:*",
"draft:*",
"approve:*",
"manage:users"
]
},
"RESEARCHERS": {
"description": "Researchers who can submit SOPs and manage drafts",
"permissions": [
"submit:SOP*",
"view:own",
"view:group",
"draft:*"
]
},
"CLINICIANS": {
"description": "Clinicians with patient data access",
"permissions": [
"submit:SOP*",
"view:own",
"draft:*"
]
}
}
}
}
}
}
Important: Permissions are NOT hardcoded in Python. They are read from config.json at runtime, respecting schema independence.
Permission Checking¶
In Code¶
from rawscribe.routes.auth import get_current_user
@router.post("/some-endpoint")
async def my_endpoint(current_user: dict = Depends(get_current_user)):
# Check specific permission
user_permissions = current_user.get('permissions', [])
has_permission = '*' in user_permissions or 'manage:users' in user_permissions
if not has_permission:
raise HTTPException(status_code=403, detail="Requires manage:users permission")
Via API¶
Check user’s permissions in the decoded JWT token:
{
"sub": "user@example.com",
"cognito:groups": ["LAB_MANAGERS"],
"permissions": [
"submit:SOP*",
"view:*",
"draft:*",
"approve:*",
"manage:users"
]
}
Adding New Permissions¶
1. Define Permission in auth.py¶
permission_mapping = {
'LAB_MANAGERS': [
'submit:SOP*',
'view:*',
'draft:*',
'approve:*',
'manage:users',
'export:data' # NEW permission
],
}
2. Use in Route Handler¶
@router.get("/v1/data/export")
async def export_data(current_user: dict = Depends(get_current_user)):
user_permissions = current_user.get('permissions', [])
has_permission = '*' in user_permissions or 'export:data' in user_permissions
if not has_permission:
raise HTTPException(status_code=403, detail="Requires export:data permission")
# ... export logic
Permission vs Group Strategy¶
Use permissions, not groups in authorization checks:
❌ Bad (tightly coupled to groups):
user_groups = current_user.get('cognito:groups', [])
if 'ADMINS' not in user_groups:
raise HTTPException(status_code=403)
✅ Good (flexible, permission-based):
user_permissions = current_user.get('permissions', [])
has_permission = '*' in user_permissions or 'manage:users' in user_permissions
if not has_permission:
raise HTTPException(status_code=403, detail="Requires manage:users permission")
Why?
Groups can change without breaking code
Multiple groups can have same permission
Clear what action is being authorized
Easier to audit and understand
Example Scenarios¶
Scenario 1: User Management¶
Who can manage users?
✅ ADMINS (have
*)✅ LAB_MANAGERS (have
manage:users)❌ RESEARCHERS (no permission)
Scenario 2: Data Approval¶
Who can approve submissions?
✅ ADMINS (have
*)✅ LAB_MANAGERS (have
approve:*)❌ RESEARCHERS (no permission)
❌ CLINICIANS (no permission)
Scenario 3: Draft Management¶
Who can manage drafts?
✅ ADMINS (have
*)✅ LAB_MANAGERS (have
draft:*)✅ RESEARCHERS (have
draft:*)✅ CLINICIANS (have
draft:*)