Skip to content

Commit f2bd48a

Browse files
committed
feat(security): implement secure demo mode with automatic user creation and route protection
1 parent 5e3a71a commit f2bd48a

10 files changed

Lines changed: 174 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,15 @@ Certifique-se de que todos os passos passem localmente antes de enviar seu códi
8282

8383
---
8484

85+
## 🛡️ Modo Demonstração (Demo Mode)
86+
87+
O sistema possui uma trava de segurança para contas de demonstração:
88+
- **Identificação:** Usuários com a flag `is_demo: true` no banco de dados.
89+
- **Restrição:** Bloqueio automático de atualizações de perfil (nome, email, senha) via dependência `forbid_demo_user`.
90+
- **Automação:** Criação automática de usuário demo via variáveis `DEMO_USER_USERNAME` e `DEMO_USER_PASSWORD`.
91+
92+
---
93+
8594
## 🚀 Deployment
8695

8796
O deploy da aplicação é realizado na plataforma **Render** utilizando **Docker**.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""add is_demo field to user model
2+
3+
Revision ID: cbaf2fee6e9b
4+
Revises: 0de60751eea2
5+
Create Date: 2026-05-11 15:15:59.722259
6+
7+
"""
8+
9+
from collections.abc import Sequence
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = "cbaf2fee6e9b"
16+
down_revision: str | Sequence[str] | None = "0de60751eea2"
17+
branch_labels: str | Sequence[str] | None = None
18+
depends_on: str | Sequence[str] | None = None
19+
20+
21+
def upgrade() -> None:
22+
"""Upgrade schema."""
23+
# ### commands auto generated by Alembic - please adjust! ###
24+
with op.batch_alter_table("user", schema=None) as batch_op:
25+
batch_op.add_column(sa.Column("is_demo", sa.Boolean(), nullable=False))
26+
27+
# ### end Alembic commands ###
28+
29+
30+
def downgrade() -> None:
31+
"""Downgrade schema."""
32+
# ### commands auto generated by Alembic - please adjust! ###
33+
with op.batch_alter_table("user", schema=None) as batch_op:
34+
batch_op.drop_column("is_demo")
35+
36+
# ### end Alembic commands ###

render.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ services:
1717
sync: false
1818
- key: FIRST_SUPERUSER_PASSWORD
1919
sync: false
20+
- key: DEMO_USER_USERNAME
21+
sync: false
22+
- key: DEMO_USER_PASSWORD
23+
sync: false
2024

2125
databases:
2226
- name: fastapibank-db

src/commands/init_db.py

Lines changed: 48 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""Database initialization script.
22
3-
Checks if an initial superuser should be created and creates it if needed.
3+
Checks if an initial superuser and a demo user should be created.
44
"""
55

66
import asyncio
@@ -19,41 +19,60 @@
1919
logger = logging.getLogger(__name__)
2020

2121

22-
async def init_db() -> None:
23-
"""Create the initial superuser if configured."""
24-
if (
25-
not settings.first_superuser_username
26-
or not settings.first_superuser_password
27-
):
28-
logger.info(
29-
"First superuser credentials not configured. Skipping initialization."
22+
async def create_user_if_not_exists(
23+
session: AsyncSession,
24+
username: str,
25+
password: str,
26+
is_demo: bool = False,
27+
description: str = "user",
28+
) -> None:
29+
"""Create a user if they don't already exist in the database."""
30+
statement = select(User).where(User.username == username)
31+
result = await session.exec(statement)
32+
user = result.first()
33+
34+
if user:
35+
logger.info("%s '%s' already exists. Skipping.", description, username)
36+
else:
37+
logger.info("Creating %s '%s'.", description, username)
38+
user_in = UserIn(
39+
username=username,
40+
plain_password=password,
3041
)
31-
return
42+
new_user = User(
43+
**user_in.model_dump(exclude={"plain_password"}), is_demo=is_demo
44+
)
45+
new_user.hashed_password = get_password_hash(user_in.plain_password)
46+
session.add(new_user)
47+
await session.commit()
48+
logger.info("%s '%s' created successfully.", description, username)
49+
3250

51+
async def init_db() -> None:
52+
"""Initialize the database with default users."""
3353
engine = create_async_engine(settings.database_url)
3454

3555
async with AsyncSession(engine) as session:
36-
statement = select(User).where(
37-
User.username == settings.first_superuser_username
38-
)
39-
result = await session.exec(statement)
40-
user = result.first()
41-
42-
if user:
43-
logger.info("Superuser already exists. Skipping creation.")
44-
else:
45-
logger.info("Creating initial superuser.")
46-
user_in = UserIn(
47-
username=settings.first_superuser_username,
48-
plain_password=settings.first_superuser_password,
56+
if (
57+
settings.first_superuser_username
58+
and settings.first_superuser_password
59+
):
60+
await create_user_if_not_exists(
61+
session,
62+
settings.first_superuser_username,
63+
settings.first_superuser_password,
64+
is_demo=False,
65+
description="Superuser",
4966
)
50-
new_user = User(**user_in.model_dump(exclude={"plain_password"}))
51-
new_user.hashed_password = get_password_hash(
52-
user_in.plain_password
67+
68+
if settings.demo_user_username and settings.demo_user_password:
69+
await create_user_if_not_exists(
70+
session,
71+
settings.demo_user_username,
72+
settings.demo_user_password,
73+
is_demo=True,
74+
description="Demo user",
5375
)
54-
session.add(new_user)
55-
await session.commit()
56-
logger.info("Initial superuser created successfully.")
5776

5877
await engine.dispose()
5978

src/controllers/user.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from src.schemas.user import User, UserDB, UserUpdateIn
1212
from src.services.user import UserServiceDep
1313
from src.utils.security import (
14+
forbid_demo_user,
1415
get_current_active_user,
1516
)
1617

@@ -36,7 +37,7 @@ async def read_user_me(
3637
@router.patch("/me/", response_model=User)
3738
async def update_user_me(
3839
user_update_in: UserUpdateIn,
39-
current_user: Annotated[UserDB, Depends(get_current_active_user)],
40+
current_user: Annotated[UserDB, Depends(forbid_demo_user)],
4041
user_service: UserServiceDep,
4142
):
4243
"""Update the profile of the currently authenticated user.

src/core/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ def assemble_db_url(cls, v: str) -> str:
4848
first_superuser_username: str | None = Field(default=None)
4949
first_superuser_password: str | None = Field(default=None)
5050

51+
demo_user_username: str | None = Field(default=None)
52+
demo_user_password: str | None = Field(default=None)
53+
5154
model_config = SettingsConfigDict(env_file=".env")
5255

5356

src/models/user.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,4 @@ class User(Base, table=True):
2525
full_name: str | None = None
2626
disabled: bool | None = None
2727
hashed_password: str
28+
is_demo: bool = False

src/utils/security.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,3 +131,28 @@ def get_current_active_user(
131131
if current_user.disabled:
132132
raise HTTPException(status_code=400, detail="Inactive user")
133133
return current_user
134+
135+
136+
def forbid_demo_user(
137+
current_user: Annotated[User, Depends(get_current_active_user)],
138+
):
139+
"""Ensure the current user is not a demo user.
140+
141+
Used to protect sensitive operations from being performed by demo accounts.
142+
143+
Args:
144+
current_user: The authenticated active user.
145+
146+
Returns:
147+
The current user if they are not a demo user.
148+
149+
Raises:
150+
HTTPException: 403 if the user is a demo user.
151+
152+
"""
153+
if current_user.is_demo:
154+
raise HTTPException(
155+
status_code=status.HTTP_403_FORBIDDEN,
156+
detail="Action not permitted in demonstration mode.",
157+
)
158+
return current_user

tests/conftest.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,33 @@ async def access_token(client: AsyncClient):
9090
)
9191

9292
return response.json()["access_token"]
93+
94+
95+
@pytest_asyncio.fixture
96+
async def demo_access_token(client: AsyncClient):
97+
async with AsyncSession(async_engine) as session:
98+
test_password = secrets.token_urlsafe(16)
99+
test_user = User(
100+
username="demo_user",
101+
hashed_password=get_password_hash(test_password),
102+
is_demo=True,
103+
)
104+
session.add(test_user)
105+
await session.commit()
106+
await session.refresh(test_user)
107+
await session.close()
108+
109+
response = await client.post(
110+
"/api/auth/token",
111+
data={
112+
"username": test_user.username,
113+
"password": test_password,
114+
"grant_type": "password",
115+
},
116+
headers={
117+
"Authorization": "Bearer token",
118+
"Content-Type": "application/x-www-form-urlencoded",
119+
},
120+
)
121+
122+
return response.json()["access_token"]

tests/integration/controllers/test_user.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,19 @@ async def test_update_user_me_password_success(
5555
async def test_user_me_unauthorized(client: AsyncClient):
5656
response = await client.get("/api/users/me/")
5757
assert response.status_code == codes.UNAUTHORIZED
58+
59+
60+
async def test_update_user_me_fail_for_demo_user(
61+
client: AsyncClient, demo_access_token: str
62+
):
63+
response = await client.patch(
64+
"/api/users/me/",
65+
json={"full_name": "Should Fail"},
66+
headers={"Authorization": f"Bearer {demo_access_token}"},
67+
)
68+
69+
assert response.status_code == codes.FORBIDDEN
70+
assert (
71+
response.json()["detail"]
72+
== "Action not permitted in demonstration mode."
73+
)

0 commit comments

Comments
 (0)