DEV Community

DEVunderdog
DEVunderdog

Posted on

Building NeuroStash - III

Part 3 of the NeuroStash series - Building an enterprise-grade knowledge management platform

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"
Enter fullscreen mode Exit fullscreen mode

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
    )
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

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"}
Enter fullscreen mode Exit fullscreen mode

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" 
    )
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: Code

Top comments (0)