Building NeuroStash - III

Farhan KhojaFarhan Khoja
6 min read

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:

  1. Over-engineering: Complex permission matrices that become unmaintainable

  2. 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:

  1. Admin-only registration: Prevents unauthorized signups

  2. Automatic API key generation: Every user gets immediate API access

  3. Default USER role: Principle of least privilege

  4. 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

0
Subscribe to my newsletter

Read articles from Farhan Khoja directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Farhan Khoja
Farhan Khoja