Let’s Start With a Real Problem
Your blogging API is live. Users can log in and create posts. One day, a user named Priya notices something alarming: she can send a DELETE /posts/47 request — a post that belongs to a completely different user, someone she’s never even met — and it just… works. Your get_current_user dependency from Chapters 15 and 16 correctly confirmed who Priya is. But nothing ever checked whether Priya was actually allowed to delete post 47.
This is the difference between authentication (“who are you?”) and authorization (“what are you allowed to do?”). Chapter 16 gave us role-based authorization (regular user vs. admin). This chapter covers something just as common and just as important: ownership-based authorization — making sure a user can only modify their own data, not anyone else’s.
What You’ll Learn
- The precise difference between authentication and authorization
- How to check that the current user actually owns a resource before letting them modify or delete it
- How to combine ownership checks with admin overrides (an admin can act on anyone’s data; a regular user only their own)
- How to turn a repeated ownership check into a single, reusable FastAPI dependency
- Theory + hands-on assignment questions to lock in what you’ve learned
Theory: Authentication vs. Authorization
| Authentication | Authorization | |
|---|---|---|
| Question it answers | “Who is making this request?” | “Is this specific person allowed to do this specific thing?” |
| Where we built it | Chapters 14–16 (get_current_user) | Chapter 16 (roles) and this chapter (ownership) |
| Failure status code | 401 Unauthorized | 403 Forbidden |
A request can be perfectly authenticated (a real, valid, logged-in user) and still be completely unauthorized for the specific action it’s attempting. Both checks matter, and they’re genuinely separate steps — get_current_user should only ever answer “who is this?”; a different piece of logic should answer “are they allowed to touch this particular row of data?”
Program 1: Basic Ownership Checks
Program Objective
Build a small, standalone FastAPI project where users can create posts, and only the post’s original author (or an admin) can update or delete it — with the ownership check written directly and explicitly inside each route.
Step-by-Step Implementation
Step 1: Create the project
uv init ownership-checks-demo
cd ownership-checks-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
ownership-checks-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── security.py
│ │ └── dependencies.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── post.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── token.py
│ │ └── post.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ └── post_service.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ ├── auth.py
│ └── posts.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Ownership Checks Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
jwt_secret_key: str = "a-very-insecure-demo-secret-change-this"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
settings = Settings()
app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {"sub": subject, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise ValueError("Invalid or expired token.") from exc
app/core/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.security import decode_token
from app.db.database import get_db
from app.models.user import User
from app.services.user_service import get_user_by_username
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
except ValueError:
raise credentials_exception
username = payload.get("sub")
if username is None:
raise credentials_exception
user = get_user_by_username(db, username)
if user is None:
raise credentials_exception
return user
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
app/models/post.py
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(String(2000))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
is_admin: bool
model_config = ConfigDict(from_attributes=True)
app/schemas/token.py
from pydantic import BaseModel
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
app/schemas/post.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
class PostCreate(BaseModel):
title: Annotated[str, Field(min_length=3, max_length=200)]
content: Annotated[str, Field(min_length=1, max_length=2000)]
class PostPatch(BaseModel):
title: Annotated[str | None, Field(default=None, min_length=3, max_length=200)]
content: Annotated[str | None, Field(default=None, min_length=1, max_length=2000)]
class PostOut(BaseModel):
id: int
title: str
content: str
user_id: int
model_config = ConfigDict(from_attributes=True)
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_username(db: Session, username: str) -> User | None:
return db.query(User).filter(User.username == username).first()
app/services/post_service.py
from sqlalchemy.orm import Session
from app.models.post import Post
from app.schemas.post import PostCreate
def create_post(db: Session, post_in: PostCreate, owner_id: int) -> Post:
new_post = Post(title=post_in.title, content=post_in.content, user_id=owner_id)
db.add(new_post)
db.commit()
db.refresh(new_post)
return new_post
def get_post_by_id(db: Session, post_id: int) -> Post | None:
return db.get(Post, post_id)
app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import TokenResponse
from app.services.user_service import get_user_by_username
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = get_user_by_username(db, form_data.username)
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password.")
return TokenResponse(access_token=create_access_token(subject=user.username))
app/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
app/routers/posts.py
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy.orm import Session
from app.core.dependencies import get_current_user
from app.db.database import get_db
from app.models.user import User
from app.schemas.post import PostCreate, PostOut, PostPatch
from app.services.post_service import create_post, get_post_by_id
router = APIRouter(prefix="/posts", tags=["Posts"])
@router.post("/", response_model=PostOut, status_code=status.HTTP_201_CREATED)
def create_new_post(
post_in: PostCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return create_post(db, post_in, owner_id=current_user.id)
@router.get("/{post_id}", response_model=PostOut)
def get_post(post_id: int, db: Session = Depends(get_db)):
post = get_post_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")
return post
@router.patch("/{post_id}", response_model=PostOut)
def update_post(
post_id: int,
data: PostPatch,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
post = get_post_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")
# --- Authorization check: must be the owner, or an admin ---
if post.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to modify this post.",
)
update_data = data.model_dump(exclude_unset=True)
for field_name, value in update_data.items():
setattr(post, field_name, value)
db.commit()
db.refresh(post)
return post
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_post(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
post = get_post_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")
# --- Authorization check: must be the owner, or an admin ---
if post.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to delete this post.",
)
db.delete(post)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.db.database import Base, engine
from app.models import post, user # noqa: F401
from app.routers import auth, posts, users
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
app.include_router(auth.router)
app.include_router(posts.router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
GET /posts/{post_id}has no authorization check at all — deliberately. Reading a post is public; the only actions that need protecting are ones that change data. Not every route needs authorization — only apply it where it’s actually meaningful.update_postanddelete_postfollow an identical three-step pattern:- Fetch the post — if it doesn’t exist,
404. This must happen before any authorization check, otherwise you’d leak information about which IDs exist versus which ones you’re forbidden from touching. - Check ownership —
if post.user_id != current_user.id and not current_user.is_admin: raise 403. This single line is the entire authorization logic for this program: a regular user can only act on posts whereuser_idmatches their ownid; an admin bypasses this check entirely. - Only then perform the actual update or delete.
- Fetch the post — if it doesn’t exist,
- Notice authorization always comes after authentication, but before the actual action.
Depends(get_current_user)runs first (answering “who is this?”), and only once that succeeds do we reach the ownership check (answering “are they allowed to do this specific thing?”). - Also notice this program repeats the exact same three lines of ownership-check code in two different routes. That’s a code smell worth noticing — and it’s exactly what Program 2 fixes.
Execution & Testing
uv run fastapi dev app/main.py
- Register two users, e.g.
aliceandbob, and log in as each to get two separate tokens. - As
alice, create a post viaPOST /posts/— note itsid. - As
bob(using his own token), tryPATCH /posts/{alice's post id}— confirm you get a403 Forbidden. - As
alice,PATCHthe same post — confirm it succeeds. - As
bob, tryDELETE /posts/{alice's post id}— confirm403again. - Test the admin override: manually set
bob‘sis_adminto1directly in the database (sqlite3 app.db "UPDATE users SET is_admin = 1 WHERE username = 'bob';"), log in again asbobto get a fresh token, then retryDELETE /posts/{alice's post id}— confirm it now succeeds, since admins bypass the ownership check.
Program 2: A Reusable Ownership-Checking Dependency
Program Objective
Refactor the repeated “fetch post, check 404, check ownership” logic from Program 1 into a single, reusable FastAPI dependency, so every route that needs it can simply declare it as a parameter — following the same clean-dependency philosophy from Chapters 12 and 16.
Step-by-Step Implementation
Step 1: Create the project
uv init reusable-ownership-demo
cd reusable-ownership-demo
uv add fastapi --extra standard
uv add pydantic-settings
uv add sqlalchemy
uv add "passlib[bcrypt]"
uv add "python-jose[cryptography]"
Step 2: Create the project structure
mkdir -p app/core app/db app/models app/schemas app/services app/routers
touch app/__init__.py app/core/__init__.py app/db/__init__.py app/models/__init__.py app/schemas/__init__.py app/services/__init__.py app/routers/__init__.py
reusable-ownership-demo/
├── app/
│ ├── __init__.py
│ ├── main.py
│ ├── core/
│ │ ├── __init__.py
│ │ ├── config.py
│ │ ├── security.py
│ │ └── dependencies.py
│ ├── db/
│ │ ├── __init__.py
│ │ └── database.py
│ ├── models/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ └── post.py
│ ├── schemas/
│ │ ├── __init__.py
│ │ ├── user.py
│ │ ├── token.py
│ │ └── post.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── user_service.py
│ │ └── post_service.py
│ └── routers/
│ ├── __init__.py
│ ├── users.py
│ ├── auth.py
│ └── posts.py
├── pyproject.toml
└── uv.lock
Step 3: Create each file with the code below. Step 4: Run and test.
Complete Working Source Code
Most files here are identical to Program 1. Only core/dependencies.py, routers/posts.py, and services/post_service.py change meaningfully — but every file is included below for a complete, standalone project.
app/core/config.py
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Reusable Ownership Demo"
app_version: str = "1.0.0"
database_url: str = "sqlite:///./app.db"
jwt_secret_key: str = "a-very-insecure-demo-secret-change-this"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
settings = Settings()
app/core/security.py
from datetime import datetime, timedelta, timezone
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.core.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain_password: str) -> str:
return pwd_context.hash(plain_password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
to_encode = {"sub": subject, "exp": expire}
return jwt.encode(to_encode, settings.jwt_secret_key, algorithm=settings.jwt_algorithm)
def decode_token(token: str) -> dict:
try:
return jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm])
except JWTError as exc:
raise ValueError("Invalid or expired token.") from exc
app/core/dependencies.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from app.core.security import decode_token
from app.db.database import get_db
from app.models.post import Post
from app.models.user import User
from app.services.post_service import get_post_by_id
from app.services.user_service import get_user_by_username
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials.",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(token)
except ValueError:
raise credentials_exception
username = payload.get("sub")
if username is None:
raise credentials_exception
user = get_user_by_username(db, username)
if user is None:
raise credentials_exception
return user
def get_owned_post(
post_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
) -> Post:
"""Fetches a post and verifies the current user may modify it (owner or admin)."""
post = get_post_by_id(db, post_id)
if post is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Post not found.")
if post.user_id != current_user.id and not current_user.is_admin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have permission to modify this post.",
)
return post
app/db/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import settings
engine = create_engine(settings.database_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
class Base(DeclarativeBase):
pass
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
app/models/user.py
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
username: Mapped[str] = mapped_column(String(30), unique=True, index=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
app/models/post.py
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.database import Base
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True, index=True)
title: Mapped[str] = mapped_column(String(200))
content: Mapped[str] = mapped_column(String(2000))
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
app/schemas/user.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class UserCreate(BaseModel):
username: Annotated[str, Field(min_length=3, max_length=30)]
email: Annotated[EmailStr, Field()]
password: Annotated[str, Field(min_length=8, max_length=64)]
class UserOut(BaseModel):
id: int
username: str
email: EmailStr
is_admin: bool
model_config = ConfigDict(from_attributes=True)
app/schemas/token.py
from pydantic import BaseModel
class TokenResponse(BaseModel):
access_token: str
token_type: str = "bearer"
app/schemas/post.py
from typing import Annotated
from pydantic import BaseModel, ConfigDict, Field
class PostCreate(BaseModel):
title: Annotated[str, Field(min_length=3, max_length=200)]
content: Annotated[str, Field(min_length=1, max_length=2000)]
class PostPatch(BaseModel):
title: Annotated[str | None, Field(default=None, min_length=3, max_length=200)]
content: Annotated[str | None, Field(default=None, min_length=1, max_length=2000)]
class PostOut(BaseModel):
id: int
title: str
content: str
user_id: int
model_config = ConfigDict(from_attributes=True)
app/services/user_service.py
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.models.user import User
from app.schemas.user import UserCreate
def create_user(db: Session, user_in: UserCreate) -> User:
new_user = User(
username=user_in.username,
email=user_in.email,
hashed_password=hash_password(user_in.password),
)
db.add(new_user)
db.commit()
db.refresh(new_user)
return new_user
def get_user_by_username(db: Session, username: str) -> User | None:
return db.query(User).filter(User.username == username).first()
app/services/post_service.py
from sqlalchemy.orm import Session
from app.models.post import Post
from app.schemas.post import PostCreate, PostPatch
def create_post(db: Session, post_in: PostCreate, owner_id: int) -> Post:
new_post = Post(title=post_in.title, content=post_in.content, user_id=owner_id)
db.add(new_post)
db.commit()
db.refresh(new_post)
return new_post
def get_post_by_id(db: Session, post_id: int) -> Post | None:
return db.get(Post, post_id)
def apply_post_patch(db: Session, post: Post, data: PostPatch) -> Post:
update_data = data.model_dump(exclude_unset=True)
for field_name, value in update_data.items():
setattr(post, field_name, value)
db.commit()
db.refresh(post)
return post
def remove_post(db: Session, post: Post) -> None:
db.delete(post)
db.commit()
app/routers/auth.py
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from app.core.security import create_access_token, verify_password
from app.db.database import get_db
from app.schemas.token import TokenResponse
from app.services.user_service import get_user_by_username
router = APIRouter(prefix="/auth", tags=["Auth"])
@router.post("/login", response_model=TokenResponse)
def login(form_data: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)):
user = get_user_by_username(db, form_data.username)
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect username or password.")
return TokenResponse(access_token=create_access_token(subject=user.username))
app/routers/users.py
from fastapi import APIRouter, Depends, status
from sqlalchemy.orm import Session
from app.db.database import get_db
from app.schemas.user import UserCreate, UserOut
from app.services.user_service import create_user
router = APIRouter(prefix="/users", tags=["Users"])
@router.post("/register", response_model=UserOut, status_code=status.HTTP_201_CREATED)
def register_user(user_in: UserCreate, db: Session = Depends(get_db)):
return create_user(db, user_in)
app/routers/posts.py
from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.orm import Session
from app.core.dependencies import get_current_user, get_owned_post
from app.db.database import get_db
from app.models.post import Post
from app.models.user import User
from app.schemas.post import PostCreate, PostOut, PostPatch
from app.services.post_service import apply_post_patch, create_post, remove_post
router = APIRouter(prefix="/posts", tags=["Posts"])
@router.post("/", response_model=PostOut, status_code=status.HTTP_201_CREATED)
def create_new_post(
post_in: PostCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
return create_post(db, post_in, owner_id=current_user.id)
@router.patch("/{post_id}", response_model=PostOut)
def update_post(
data: PostPatch,
post: Post = Depends(get_owned_post),
db: Session = Depends(get_db),
):
return apply_post_patch(db, post, data)
@router.delete("/{post_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_post(post: Post = Depends(get_owned_post), db: Session = Depends(get_db)):
remove_post(db, post)
return Response(status_code=status.HTTP_204_NO_CONTENT)
app/main.py
from fastapi import FastAPI
from app.core.config import settings
from app.db.database import Base, engine
from app.models import post, user # noqa: F401
from app.routers import auth, posts, users
Base.metadata.create_all(bind=engine)
app = FastAPI(title=settings.app_name, version=settings.app_version)
app.include_router(users.router)
app.include_router(auth.router)
app.include_router(posts.router)
@app.get("/", tags=["Root"])
def read_root():
return {"message": f"Welcome to {settings.app_name}"}
Detailed Code Explanation
get_owned_postis the whole point of this program — it combines everything Program 1 repeated inline: fetch the post (404if missing), check ownership (403if not owner/admin), and finally return the actualPostobject if everything checks out. FastAPI treats this exactly like any other dependency.- Compare the two versions of
update_post: Program 1’s version was around 15 lines and mixed three concerns together (fetching, authorizing, updating). Program 2’s version is 5 lines and does exactly one thing: apply the patch to an already-verified, already-fetchedpost.delete_postis even shorter. post: Post = Depends(get_owned_post)— notice FastAPI automatically pullspost_idout of the URL path and feeds it intoget_owned_post‘s ownpost_id: intparameter, even thoughupdate_postanddelete_postnever mentionpost_iddirectly in their own signatures anymore. FastAPI resolves this chain of dependencies (and their shared path parameters) automatically.post_service.pyalso grew two small, focused functions —apply_post_patchandremove_post— completing the separation of concerns: authorization logic lives in the dependency, data-mutation logic lives in the service layer, and the router is left doing almost nothing but wiring the two together.- This is the exact same lesson from Chapter 12’s router-level dependencies, applied one level deeper: whenever the same check shows up in more than one route, that’s your signal to extract it into a dependency.
Execution & Testing
uv run fastapi dev app/main.py
Repeat the exact same test sequence from Program 1:
- Register
aliceandbob, log in as both. - Create a post as
alice. - Try
PATCHandDELETEasbob— confirm both fail with403. - Try
PATCHandDELETEasalice— confirm both succeed (test them on two separate posts, sinceDELETEremoves it). - Promote
bobto admin in the database, log in again, and confirm he can now modifyalice‘s remaining post.
Every behavior is identical to Program 1 — proving that refactoring toward a reusable dependency changed how the code is organized, not what it does.
Tips & Instructions for Developers
- ✅ Always separate “does this resource exist?” (
404) from “is this user allowed to touch it?” (403) — check existence first, always. - ✅ Extract a repeated authorization check into a dependency the moment you notice it appearing in a second route — don’t wait for a third.
- ✅ Let dependencies return the actual resource (like
Post), not just a boolean — it saves your route from re-fetching data that was already loaded during the check. - ✅ Always design an “admin override” path deliberately, and test it explicitly — it’s very easy to build ownership logic that accidentally locks out admins too.
- ✅ Keep ownership logic in one central place (a dependency or service function) so a future change to your permission rules doesn’t require hunting through every route.
- ❌ Don’t perform the authorization check before confirming the resource exists — leaking “this ID would have been forbidden” information for IDs that don’t even exist is a subtle but real information leak.
- ❌ Don’t trust any ID in the request body for ownership decisions — always compare against
current_user.idfrom the verified token, never auser_idfield a client could simply type into a request themselves.
Official FastAPI Documentation References
- Security — Get Current User
- Dependencies — Sub-dependencies
- Dependencies — Classes as Dependencies
- Path Parameters
- Handling Errors
Theory Questions
- What’s the precise difference between authentication and authorization, and which status codes correspond to each failing?
- Why must the “does this resource exist?” check always happen before the “is this user allowed to modify it?” check?
- Why is
get_owned_postallowed to depend onget_current_user, and what does FastAPI do with that chain automatically? - Why is it dangerous to trust a
user_idfield sent in a request body for ownership decisions, instead of usingcurrent_user.id? - What specific problem does extracting
get_owned_postinto its own dependency solve, compared to Program 1’s inline version?
Practical Assignment
- Apply the pattern to a new resource: Starting from Program 2’s project, add a
Commentmodel (belonging to aPost, owned by aUser), and build aget_owned_commentdependency following the same pattern, used to protectPATCHandDELETEroutes for comments. - Add a “read-only for others” rule: Starting from Program 2’s project, add a
GET /posts/mineendpoint that returns only the current user’s own posts, usingcurrent_user.idto filter the query — a different, read-focused kind of authorization than the write-protection you’ve built so far. - Break it on purpose: Starting from Program 1’s project (the inline version), deliberately swap the order of the two checks in
update_post— perform the ownership check before fetching the post — and observe what happens when you request apost_idthat doesn’t exist at all while not being an admin. Write down, in your own words, why the error you get back is misleading, and why the original order avoided it.
Summary
In this chapter, you learned:
- The precise, practical difference between authentication and authorization
- How to check resource ownership before allowing a user to update or delete data
- How to combine ownership checks with an admin override
- How to extract a repeated authorization check into a single, reusable FastAPI dependency that also hands back the already-verified resource
