Building NeuroStash - III

In my previous post, I walked through building a robust authentication system for NeuroStash using JWT tokens, API keys, and AWS KMS encryption. Today, I'm diving deeper into what happens after authentication - how I designed role-based access control (RBAC) and user management.
The User Management Challenge
After solving authentication, I faced a new set of questions:
How do you onboard new users securely?
What happens when someone needs elevated permissions?
How do you prevent privilege escalation attacks?
How do you make role management intuitive for administrators?
Most engineers fall into two traps:
Over-engineering: Complex permission matrices that become unmaintainable
Under-engineering: Simple boolean flags that don't scale
I needed something in between - powerful enough for enterprise needs, simple enough to reason about.
Architecture Overview: The Foundation
Building on the authentication system from Part II, here's how the RBAC system works:
# The core role enum - keeping it simple but extensible
class ClientRoleEnum(str, Enum):
USER = "user"
ADMIN = "admin"
Why only two roles? In my experience, most SaaS products start with user/admin and only add complexity when needed. NeuroStash follows this principle - you can always extend later, but you can't easily simplify.
User Registration: Admin-Controlled Onboarding
Instead of public registration (a security nightmare for enterprise), I implemented admin-controlled user onboarding:
@router.post("/register", response_model=UserClientCreated)
def register_user_to_app(
user_in: RegisterUser,
db: SessionDep,
token_manager: TokenDep,
admin_payload: TokenPayloadDep, # Admin verification
):
# First, verify the requester is an admin
if admin_payload.role != ClientRoleEnum.ADMIN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="you are not authorized to perform this action",
)
# Generate API key for the new user
api_key, api_key_bytes, api_key_signature, active_key_id = (
token_manager.generate_api_key()
)
# Create user with default USER role
user = UserClientCreate(email=user_in.email, role=ClientRoleEnum.USER)
api_key_params = ApiKeyCreate(
key_id=active_key_id,
key_credential=api_key_bytes,
key_signature=api_key_signature,
)
db_user_client, db_api_key = register_user(
db=db, user=user, api_key_params=api_key_params
)
return UserClientCreated(
id=db_user_client.id,
email=db_user_client.email,
api_key=api_key # Returned to admin for sharing
)
Key Design Decisions:
Admin-only registration: Prevents unauthorized signups
Automatic API key generation: Every user gets immediate API access
Default USER role: Principle of least privilege
API key returned to admin: Secure key distribution workflow
Role Promotion: Controlled Privilege Escalation
Promoting users to admin requires careful handling:
@router.patch("/promote/{user_id}", response_model=StandardResponse)
def promote_users(
user_id: int,
db: SessionDep,
admin_payload: TokenPayloadDep,
):
# Verify admin permissions
if admin_payload.role != ClientRoleEnum.ADMIN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="you are not authorized to perform this action",
)
# Validate user_id
if user_id == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="please provide user_id to promote",
)
# Perform the promotion
user_client = promote_user_db(db=db, user_id=user_id)
if user_client is None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="cannot find user with provided id",
)
return StandardResponse(message="successfully promoted user to admin")
The database logic handles idempotency elegantly:
def promote_user_db(*, db: Session, user_id: int) -> UserClient | None:
stmt = select(UserClient).where(UserClient.id == user_id)
user_client = db.scalars(stmt).first()
if user_client:
# Check if already admin (idempotent operation)
if user_client.role == ClientRoleEnum.ADMIN:
return user_client
# Promote to admin
user_client.role = ClientRoleEnum.ADMIN
try:
db.commit()
return user_client
except Exception:
db.rollback()
raise
else:
return None
User Deletion: Preventing Self-Destruction
Deleting users requires protection against admin self-deletion:
@router.delete("/delete/{user_id}", response_model=StandardResponse)
def delete_users(
user_id: int,
db: SessionDep,
admin_payload: TokenPayloadDep,
):
# Standard admin check
if admin_payload.role != ClientRoleEnum.ADMIN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="you are not authorized to perform this action",
)
# Prevent self-deletion (critical!)
if admin_payload.user_id == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="you cannot delete yourself"
)
# Proceed with deletion
deleted = delete_user_db(db=db, user_id=user_id)
if not deleted:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="cannot find user with provided id",
)
return StandardResponse(message="user deleted successfully")
The self-deletion check is crucial - I've seen production systems where admins accidentally locked themselves out. This simple check prevents that nightmare scenario.
Dependency Injection: Clean Security Boundaries
FastAPI's dependency injection makes role checks clean and reusable:
# Clean type annotations for dependencies
TokenPayloadDep = Annotated[TokenData, Depends(get_token_payload)]
ApiPayloadDep = Annotated[ApiData, Depends(get_api_payload)]
# Usage in routes
@router.get("/admin-only-endpoint")
def admin_only_route(payload: TokenPayloadDep):
if payload.role != ClientRoleEnum.ADMIN:
raise HTTPException(status_code=401, detail="admin required")
# Admin logic here
return {"message": "admin access granted"}
This pattern offers several benefits:
Consistent authentication: Every route gets the same validation
Type safety: PayloadDep ensures you have user context
Separation of concerns: Auth logic stays in dependencies
Database Schema: The Foundation Layer
The user schema supports the RBAC system with careful constraints:
class UserClient(Base, TimestampMixin):
__tablename__ = "user_clients"
id: Mapped[int] = mapped_column(Integer, Identity(), primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
role: Mapped[ClientRoleEnum] = mapped_column(
Enum(ClientRoleEnum),
nullable=False,
default=ClientRoleEnum.USER
)
# Relationship to API keys
api_keys: Mapped[List["ApiKey"]] = relationship(
"ApiKey",
back_populates="user_client",
cascade="all, delete-orphan"
)
Key design elements:
Unique email constraint: Prevents duplicate accounts
Default USER role: Secure by default
Cascade deletion: API keys are cleaned up automatically
Timestamp mixin: Audit trail for user operations
User Listing: Admin Visibility with Pagination
Admins need visibility into the user base:
@router.get("/list", response_model=ListUsers)
def list_users(
admin_payload: TokenPayloadDep,
db: SessionDep,
limit: int = 10,
offset: int = 0,
):
if admin_payload.role != ClientRoleEnum.ADMIN:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="you are not authorized to perform this action",
)
users = list_users_db(db=db, limit=limit, offset=offset)
return ListUsers(message="successfully fetched users", users=users)
The database function handles pagination efficiently:
def list_users_db(*, db: Session, limit: int = 10, offset: int = 0) -> List[UserClient]:
stmt = select(UserClient).order_by(UserClient.id).limit(limit).offset(offset)
users = db.scalars(stmt).all()
return users
Why pagination matters: Even with hundreds of users, the API stays responsive. The order_by(
UserClient.id
)
ensures consistent ordering across pages.
Integration with Authentication System
The RBAC system builds seamlessly on the authentication foundation from Part II:
# From authentication (Part II)
async def get_token_payload(
token: Annotated[Optional[str], Depends(oauth2_scheme)],
token_manager: TokenDep,
) -> TokenData:
# JWT verification logic...
return TokenData(email=payload.email, user_id=payload.user_id, role=payload.role)
This layered approach separates authentication (who are you?) from authorization (what can you do?).
Real-World Usage Examples
Here's how the complete system works in practice:'
# 1. Admin registers a new user
POST /user/register
Authorization: Bearer <admin-jwt-token>
{
"email": "newuser@company.com"
}
# Returns: API key for the new user
# 2. New user generates their own JWT token
GET /auth/generate/token
Authorization: ApiKey <user-api-key>
# Returns: JWT token for session use
# 3. Admin promotes user to admin
PATCH /user/promote/123
Authorization: Bearer <admin-jwt-token>
# User 123 now has admin privileges
# 4. Admin lists all users
GET /user/list?limit=20&offset=0
Authorization: Bearer <admin-jwt-token>
# Returns: Paginated user list
Lessons Learned & Best Practices
1. Start Simple, Scale Thoughtfully Two roles (USER/ADMIN) cover 90% of use cases. Don't build complex permission systems until you need them.
2. Admin Self-Destruction Prevention Always check if an admin is trying to delete/demote themselves. This simple check prevents lockout scenarios.
3. Idempotent Operations Promoting an admin should succeed, not fail. Design operations to be safe to retry.
4. Consistent Error Messages Standardize authorization error messages. Don't leak internal system details.
5. Dependency Injection for Security FastAPI's dependency system makes security checks clean
Want to see the full implementation? Check out the NeuroStash repository: https://github.com/DEVunderdog/NeuroStash
Subscribe to my newsletter
Read articles from Farhan Khoja directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
