DEV Community

Cover image for Build Your Own Forum with FastAPI: Step 7 - Permissions
Leapcell
Leapcell

Posted on

Build Your Own Forum with FastAPI: Step 7 - Permissions

Cover

In the previous article, we implemented comments and replies for our forum, which greatly enhanced community interaction.

Interaction, however, can inevitably lead to conflict. As interaction increases, community management becomes a problem we must face. What if someone posts malicious content?

In this article, we will introduce a basic permission management system. We will establish an "Admin" role and give administrators the ability to "ban" users to maintain community order.

Step 1: Update the Database Model

We need to add two fields to the user table (users): one to identify who is an admin, and another to mark who has been "banned."

Open models.py and modify the User model:

models.py (Update User model)

from sqlalchemy import Column, Integer, String, ForeignKey, Boolean
from sqlalchemy.orm import relationship
from database import Base

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    hashed_password = Column(String)

    # --- New fields ---
    is_admin = Column(Boolean, default=False)
    is_banned = Column(Boolean, default=False)
    # ---------------

    posts = relationship("Post", back_populates="owner", cascade="all, delete-orphan")
    comments = relationship("Comment", back_populates="owner", cascade="all, delete-orphan")

# ... Post and Comment models remain unchanged ...
Enter fullscreen mode Exit fullscreen mode

We've added two fields: is_admin and is_banned. Both are set to default=False to avoid affecting existing users.

After updating the model, you need to manually update your database table structure. The corresponding SQL statements are as follows:

-- Add is_admin column to users table
ALTER TABLE users ADD COLUMN is_admin BOOLEAN DEFAULT FALSE;

-- Add is_banned column to users table
ALTER TABLE users ADD COLUMN is_banned BOOLEAN DEFAULT FALSE;
Enter fullscreen mode Exit fullscreen mode

If your database was created using Leapcell,

ImageLc

you can execute these SQL statements directly in its web-based operation panel.

ImageDb

Step 2: Manually Appoint an Administrator

Our forum doesn't have an "admin backend" to appoint administrators yet. Since creating an admin is an infrequent requirement, we can just operate the database directly to manually set your user as an admin.

Execute the following command in your database:

-- Set the user with username 'your_username' as an admin
UPDATE users SET is_admin = TRUE WHERE username = 'your_username';
Enter fullscreen mode Exit fullscreen mode

Remember to replace your_username with the username you registered with.

Step 3: Create the Admin Panel Page

We need a page that only administrators can access, which will display all users and provide action buttons.

In the templates folder, create a new file named admin.html.

templates/admin.html

<!DOCTYPE html>
<html>
  <head>
    <title>Admin Panel - My FastAPI Forum</title>
    <style>
      body {
        font-family: sans-serif;
        margin: 2em;
      }
      li {
        margin-bottom: 10px;
      }
      button {
        margin-left: 10px;
        padding: 5px;
      }
    </style>
  </head>
  <body>
    <h1>Admin Panel - User Management</h1>
    <a href="/posts">Back to Home</a>
    <hr />
    <ul>
      {% for user in users %}
      <li>
        <strong>{{ user.username }}</strong>
        <span>(Admin: {{ user.is_admin }}, Banned: {{ user.is_banned }})</span>

        {% if not user.is_admin %} {% if user.is_banned %}
        <form action="/admin/unban/{{ user.id }}" method="post" style="display: inline;">
          <button type="submit" style="background-color: #28a745; color: white;">Unban</button>
        </form>
        {% else %}
        <form action="/admin/ban/{{ user.id }}" method="post" style="display: inline;">
          <button type="submit" style="background-color: #dc3545; color: white;">Ban</button>
        </form>
        {% endif %} {% endif %}
      </li>
      {% endfor %}
    </ul>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

This page iterates through all users. If a user is not an admin, a "Ban" or "Unban" button will be displayed next to them. These buttons will point to the API routes we are about to create via a POST request.

Step 4: Implement the Admin Backend Routes

Now, we need to add new routes in main.py to handle the admin panel logic.

main.py (Add new routes and dependency)

# ... (Previous imports remain unchanged) ...

# --- Dependencies ---

# ... (get_current_user remains unchanged) ...

# 1. Add a new dependency to verify admin permissions
async def get_admin_user(
    current_user: Optional[models.User] = Depends(get_current_user)
) -> models.User:
    if not current_user:
        raise HTTPException(
            status_code=status.HTTP_302_FOUND,
            detail="Not authenticated",
            headers={"Location": "/login"}
        )
    if not current_user.is_admin:
        # If not an admin, raise a 403 error
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="You do not have permission to access this resource."
        )
    return current_user

# --- Routes ---

# ... (Previous routes /, /posts, /api/posts, etc., remain unchanged) ...

# 2. Add admin panel route
@app.get("/admin", response_class=HTMLResponse)
async def view_admin_panel(
    request: Request,
    db: AsyncSession = Depends(get_db),
    admin_user: models.User = Depends(get_admin_user)
):
    # Query all users
    result = await db.execute(select(models.User).order_by(models.User.id))
    users = result.scalars().all()

    return templates.TemplateResponse("admin.html", {
        "request": request,
        "users": users
    })

# 3. Ban user route
@app.post("/admin/ban/{user_id}")
async def ban_user(
    user_id: int,
    db: AsyncSession = Depends(get_db),
    admin_user: models.User = Depends(get_admin_user)
):
    result = await db.execute(select(models.User).where(models.User.id == user_id))
    user = result.scalar_one_or_none()

    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    # Admins cannot ban other admins
    if user.is_admin:
        raise HTTPException(status_code=403, detail="Cannot ban an admin")

    user.is_banned = True
    await db.commit()
    return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)

# 4. Unban user route
@app.post("/admin/unban/{user_id}")
async def unban_user(
    user_id: int,
    db: AsyncSession = Depends(get_db),
    admin_user: models.User = Depends(get_admin_user)
):
    result = await db.execute(select(models.User).where(models.User.id == user_id))
    user = result.scalar_one_or_none()

    if not user:
        raise HTTPException(status_code=404, detail="User not found")

    user.is_banned = False
    await db.commit()
    return RedirectResponse(url="/admin", status_code=status.HTTP_303_SEE_OTHER)

# ... (Subsequent routes /posts/{post_id}, /posts/{post_id}/comments, etc., remain unchanged) ...
Enter fullscreen mode Exit fullscreen mode

This includes these main changes:

  • Created a new dependency get_admin_user, which is based on get_current_user and additionally checks if current_user.is_admin is True.
  • Created the GET /admin route, which queries all users and renders the admin.html template. This route is protected by Depends(get_admin_user) to ensure only admins can access it.
  • Created POST /admin/ban/{user_id} and POST /admin/unban/{user_id} routes to ban/unban specific users.

Step 5: Enforce the Ban (Prevent Posting)

A user can now be marked as "banned," but their actions are not yet affected. A banned user can still create posts and comments.

We need to modify the create_post and create_comment routes to check the user's status before performing the action.

main.py (Update create_post and create_comment)

# ... (Previous code) ...

@app.post("/api/posts")
async def create_post(
    title: str = Form(...),
    content: str = Form(...),
    db: AsyncSession = Depends(get_db),
    current_user: Optional[models.User] = Depends(get_current_user)
):
    if not current_user:
        return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)

    # --- Add check ---
    if current_user.is_banned:
        raise HTTPException(status_code=403, detail="You are banned and cannot create posts.")
    # ---------------

    new_post = models.Post(title=title, content=content, owner_id=current_user.id)
    # ... (Subsequent code remains unchanged) ...
    db.add(new_post)
    await db.commit()
    await db.refresh(new_post)
    return RedirectResponse(url="/posts", status_code=status.HTTP_303_SEE_OTHER)

# ... (Other routes) ...

@app.post("/posts/{post_id}/comments")
async def create_comment(
    post_id: int,
    content: str = Form(...),
    parent_id: Optional[int] = Form(None),
    db: AsyncSession = Depends(get_db),
    current_user: Optional[models.User] = Depends(get_current_user)
):
    if not current_user:
        return RedirectResponse(url="/login", status_code=status.HTTP_302_FOUND)

    # --- Add check ---
    if current_user.is_banned:
        raise HTTPException(status_code=403, detail="You are banned and cannot create comments.")
    # ---------------

    new_comment = models.Comment(
        content=content,
        post_id=post_id,
        owner_id=current_user.id,
        parent_id=parent_id
    )
    # ... (Subsequent code remains unchanged) ...
    db.add(new_comment)
    await db.commit()
    return RedirectResponse(url=f"/posts/{post_id}", status_code=status.HTTP_303_SEE_OTHER)

# ... (Subsequent routes /posts/{post_id}/edit, /register, /login, /logout, etc., remain unchanged) ...
Enter fullscreen mode Exit fullscreen mode

Now, if a banned user tries to submit a post or comment form, the backend will reject the request and return a 403 error.

Step 6: Update the Frontend UI

The backend is now secure, but from a user experience perspective, we should hide the posting and commenting forms from the frontend and give admins an entry point to the backend.

templates/posts.html (Update)

... (Header and styles remain unchanged) ...
<body>
    <header>
      <h1>Welcome to My Forum</h1>

      <div class="auth-links">
        {% if current_user %}
        <span>Welcome, {{ current_user.username }}!</span>
        {% if current_user.is_admin %}
        <a href="/admin" style="color: red; font-weight: bold;">[Admin Panel]</a>
        {% endif %}
        <a href="/logout">Logout</a>
        {% else %}
        <a href="/login">Login</a> |
        <a href="/register">Register</a>
        {% endif %}
      </div>
    </header>

    {% if current_user and not current_user.is_banned %}
    <h2>Create a New Post</h2>
    <form action="/api/posts" method="post">
      <input type="text" name="title" placeholder="Post Title" required /><br />
      <textarea name="content" rows="4" placeholder="Post Content" required></textarea><br />
      <button type="submit">Post</button>
    </form>
    {% elif current_user and current_user.is_banned %}
    <p style="color: red; font-weight: bold;">You have been banned and cannot create new posts.</p>
    {% else %}
    <p><a href="/login">Login</a> to create a new post.</p>
    {% endif %}

    <hr />
    ... (Post list section remains unchanged) ...
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

templates/post_detail.html (Update)

... (Header and styles remain unchanged) ...
<body>
    ... (Post detail section remains unchanged) ...
    <hr />
    <div class="comment-form">
      <h3>Post a Comment</h3>
      {% if current_user and not current_user.is_banned %}
      <form action="/posts/{{ post.id }}/comments" method="post">
        <textarea name="content" rows="4" style="width:100%;" placeholder="Write your comment..." required></textarea><br />
        <button type="submit">Submit</button>
      </form>
      {% elif current_user and current_user.is_banned %}
      <p style="color: red; font-weight: bold;">You have been banned and cannot post comments.</p>
      {% else %}
      <p><a href="/login">Log in</a> to post a comment.</p>
      {% endif %}
    </div>
    ... (Comment section remains unchanged) ...
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

This includes two main changes:

  • In the posts.html header, if the current user is an admin (current_user.is_admin), an "Admin Panel" link is displayed.
  • In posts.html and post_detail.html, the original {% if current_user %} condition is changed to {% if current_user and not current_user.is_banned %}, meaning only users who are not banned can see the form.

Run and Verify

Restart your uvicorn server:

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

Log in to your admin account. You should be able to see the "Admin Panel" link in the top right.

ImageP1

Click it to go to the /admin page. You will see a list of all users and can ban other users.

ImageP2

Ban test_user. Switch to being logged in as test_user, and you will find that the "Create New Post" form has disappeared, replaced by a "You have been banned" message.

ImageP3

Conclusion

We have added basic management functions to the forum. Using the is_admin and is_banned fields, we've supported user role differentiation and permission control.

Based on this framework, you can further extend more management functions, such as shadow-banning users or prohibiting logins.

As the forum's content grows, users might find it difficult to locate old posts they are interested in.

To address this, in the next article, we will add a search function to the forum.


Follow us on X: @LeapcellHQ


Read other articles in this series

Related Posts:

Top comments (0)