Skip to content

Commit 70178e5

Browse files
committed
fix: rate limiter
1 parent da547b5 commit 70178e5

13 files changed

Lines changed: 306 additions & 33 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,4 @@ SEED_ADMIN_EMAIL=
3939
SEED_ADMIN_PASSWORD=
4040
SEED_ADMIN_USERNAME=admin
4141
SEED_ADMIN_FULLNAME=System Administrator
42+
SEED_DEVELOPMENT_USERS_PASSWORD=

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,20 @@ SEED_ADMIN_FULLNAME=System Administrator
349349

350350
If `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PASSWORD` is empty, user seeding is skipped. Existing users are not modified.
351351

352+
When `APP_ENV=development`, the seeder can also create demo users with different roles. Set a shared development password before running `make seed`:
353+
354+
```env
355+
SEED_DEVELOPMENT_USERS_PASSWORD=
356+
```
357+
358+
Development demo accounts:
359+
360+
- `user@example.com` with the `user` role
361+
- `manager@example.com` with the `manager` role
362+
- `viewer@example.com` with the `viewer` role
363+
364+
These users are skipped outside development and are not updated if they already exist.
365+
352366
## Testing and Quality Checks
353367

354368
Run tests:

scripts/seed.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import asyncio
2+
import sys
3+
from pathlib import Path
24

3-
from src.core.seed.runner import run_seeders
5+
ROOT = Path(__file__).resolve().parents[1]
6+
sys.path.insert(0, str(ROOT))
7+
8+
from src.core.seed.runner import run_seeders # noqa: E402
49

510

611
async def main() -> None:

src/core/authorization/permissions.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ class AuthorizationResourceDefinition:
88
description: str
99

1010

11+
@dataclass(frozen=True)
12+
class AuthorizationRoleDefinition:
13+
name: str
14+
description: str
15+
16+
1117
DEFAULT_RESOURCES = (
1218
AuthorizationResourceDefinition(
1319
key="todo",
@@ -46,6 +52,27 @@ class AuthorizationResourceDefinition:
4652

4753
DEFAULT_USER_ROLE = "user"
4854
ADMIN_ROLE = "admin"
55+
MANAGER_ROLE = "manager"
56+
VIEWER_ROLE = "viewer"
57+
58+
DEFAULT_ROLES = (
59+
AuthorizationRoleDefinition(
60+
name=ADMIN_ROLE,
61+
description="Administrator with full platform access",
62+
),
63+
AuthorizationRoleDefinition(
64+
name=DEFAULT_USER_ROLE,
65+
description="Default authenticated user",
66+
),
67+
AuthorizationRoleDefinition(
68+
name=MANAGER_ROLE,
69+
description="Manager user with todo management access",
70+
),
71+
AuthorizationRoleDefinition(
72+
name=VIEWER_ROLE,
73+
description="Read-only user",
74+
),
75+
)
4976

5077

5178
def permission_key(resource: str, action: str) -> str:
@@ -59,4 +86,11 @@ def permission_key(resource: str, action: str) -> str:
5986
("p", DEFAULT_USER_ROLE, permission_key(TODO_RESOURCE, UPDATE_ACTION)),
6087
("p", DEFAULT_USER_ROLE, permission_key(TODO_RESOURCE, DELETE_ACTION)),
6188
("p", DEFAULT_USER_ROLE, permission_key(USER_RESOURCE, ME_ACTION)),
89+
("p", MANAGER_ROLE, permission_key(TODO_RESOURCE, CREATE_ACTION)),
90+
("p", MANAGER_ROLE, permission_key(TODO_RESOURCE, READ_ACTION)),
91+
("p", MANAGER_ROLE, permission_key(TODO_RESOURCE, UPDATE_ACTION)),
92+
("p", MANAGER_ROLE, permission_key(TODO_RESOURCE, DELETE_ACTION)),
93+
("p", MANAGER_ROLE, permission_key(USER_RESOURCE, ME_ACTION)),
94+
("p", VIEWER_ROLE, permission_key(TODO_RESOURCE, READ_ACTION)),
95+
("p", VIEWER_ROLE, permission_key(USER_RESOURCE, ME_ACTION)),
6296
)

src/core/bootstrap/middleware.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22
from fastapi.middleware.cors import CORSMiddleware
33

44
from src.core.config.setting import get_settings
5-
from src.core.middleware.auth import AuthenticationMiddleware
65
from src.core.middleware.audit_logging import AuditLoggingMiddleware
6+
from src.core.middleware.auth import AuthenticationMiddleware
7+
from src.core.middleware.csp import CSPMiddleware
78
from src.core.middleware.idempotency import IdempotencyMiddleware
89
from src.core.middleware.request_id import RequestIDMiddleware
910
from src.core.middleware.request_size import LimitRequestSizeMiddleware
@@ -19,6 +20,7 @@ def register_middleware(app: FastAPI):
1920
max_upload_size=settings.MAX_REQUEST_SIZE_MB,
2021
)
2122
app.add_middleware(SecurityHeadersMiddleware)
23+
app.add_middleware(CSPMiddleware)
2224
app.add_middleware(
2325
CORSMiddleware,
2426
allow_origins=settings.cors_allow_origins,

src/core/config/setting.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ class Settings(BaseSettings):
5757
alias="SEED_ADMIN_FULLNAME",
5858
default="System Administrator",
5959
)
60+
SEED_DEVELOPMENT_USERS_PASSWORD: str = Field(
61+
alias="SEED_DEVELOPMENT_USERS_PASSWORD",
62+
default="",
63+
)
6064
MAX_REQUEST_SIZE_MB: int = Field(
6165
alias="MAX_REQUEST_SIZE_MB", default=5 * 1024 * 1024
6266
)

src/core/dependency/rate_limit.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
from fastapi import Request
1+
from fastapi import HTTPException, Request, Response
22
from fastapi_limiter import FastAPILimiter
3-
from fastapi_limiter.depends import RateLimiter
43

54
from src.core.config.setting import get_settings
65
from src.core.database.redis.client import get_redis_client
@@ -55,5 +54,20 @@ async def apply_global_rate_limit(request: Request):
5554
times = int(times_str)
5655
seconds = 60 if "minute" in period else 1
5756

58-
limiter = RateLimiter(times=times, seconds=seconds)
59-
await limiter(request)
57+
# Get identifier for this request
58+
identifier = await custom_identifier(request)
59+
60+
# Check rate limit
61+
is_rate_limited = await FastAPILimiter.redis.incr(
62+
f"fastapi-limiter:{identifier}:{request.scope.get('path')}"
63+
)
64+
65+
# Set expiry on first request
66+
if is_rate_limited == 1:
67+
await FastAPILimiter.redis.expire(
68+
f"fastapi-limiter:{identifier}:{request.scope.get('path')}", seconds
69+
)
70+
71+
# Check if rate limit exceeded
72+
if is_rate_limited > times:
73+
raise HTTPException(status_code=429, detail="Rate limit exceeded")

src/core/middleware/csp.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
from starlette.middleware.base import BaseHTTPMiddleware
2+
3+
4+
class CSPMiddleware(BaseHTTPMiddleware):
5+
async def dispatch(self, request, call_next):
6+
response = await call_next(request)
7+
8+
if request.url.path.startswith("/docs") or request.url.path.startswith(
9+
"/redoc"
10+
):
11+
response.headers["Content-Security-Policy"] = (
12+
"default-src 'self'; "
13+
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
14+
"style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
15+
"img-src 'self' data: https:; "
16+
"font-src 'self' https:;"
17+
)
18+
else:
19+
response.headers["Content-Security-Policy"] = (
20+
"default-src 'self'; "
21+
"object-src 'none'; "
22+
"base-uri 'self'; "
23+
"frame-ancestors 'none';"
24+
)
25+
26+
return response

src/core/seed/authorization.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@
22
from typing import Protocol
33

44
from src.core.authorization.permissions import (
5-
ADMIN_ROLE,
65
DEFAULT_RESOURCES,
6+
DEFAULT_ROLES,
77
DEFAULT_POLICIES,
8-
DEFAULT_USER_ROLE,
98
)
109
from src.modules.authorization.domain.entities.permission import Permission
1110
from src.modules.authorization.domain.entities.resource import AuthorizationResource
@@ -142,8 +141,8 @@ async def seed_authorization(
142141

143142
def _default_roles() -> dict[str, str]:
144143
return {
145-
ADMIN_ROLE: "Administrator with full platform access",
146-
DEFAULT_USER_ROLE: "Default authenticated user",
144+
role.name: role.description
145+
for role in DEFAULT_ROLES
147146
}
148147

149148

src/core/seed/user.py

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
from dataclasses import dataclass
22
from typing import Protocol
33

4-
from src.core.authorization.permissions import ADMIN_ROLE
4+
from src.core.authorization.permissions import (
5+
ADMIN_ROLE,
6+
DEFAULT_USER_ROLE,
7+
MANAGER_ROLE,
8+
VIEWER_ROLE,
9+
)
510
from src.core.security.password import PasswordSerrvice
611
from src.modules.user.domain.entities.user import User
712

@@ -21,24 +26,35 @@ async def assign_role(self, subject: str, role: str) -> None:
2126

2227
@dataclass(frozen=True)
2328
class SeedUserConfig:
29+
app_env: str
2430
admin_email: str
2531
admin_password: str
2632
admin_username: str | None = None
2733
admin_fullname: str | None = None
34+
development_users_password: str = ""
2835

2936
@classmethod
3037
def from_settings(cls, settings) -> "SeedUserConfig":
3138
return cls(
39+
app_env=settings.APP_ENV,
3240
admin_email=settings.SEED_ADMIN_EMAIL,
3341
admin_password=settings.SEED_ADMIN_PASSWORD,
3442
admin_username=settings.SEED_ADMIN_USERNAME,
3543
admin_fullname=settings.SEED_ADMIN_FULLNAME,
44+
development_users_password=settings.SEED_DEVELOPMENT_USERS_PASSWORD,
3645
)
3746

3847
@property
3948
def has_admin_credentials(self) -> bool:
4049
return bool(self.admin_email.strip() and self.admin_password.strip())
4150

51+
@property
52+
def should_seed_development_users(self) -> bool:
53+
return (
54+
self.app_env.lower() == "development"
55+
and bool(self.development_users_password.strip())
56+
)
57+
4258

4359
@dataclass(frozen=True)
4460
class UserSeedResult:
@@ -51,20 +67,96 @@ async def seed_user(
5167
authorization_service: SeedAuthorizationService,
5268
config: SeedUserConfig,
5369
) -> UserSeedResult:
70+
users_created = 0
71+
roles_assigned = 0
72+
5473
if not config.has_admin_credentials:
55-
return UserSeedResult()
74+
admin_result = UserSeedResult()
75+
else:
76+
admin_result = await _seed_one_user(
77+
user_repository=user_repository,
78+
authorization_service=authorization_service,
79+
email=config.admin_email,
80+
password=config.admin_password,
81+
username=config.admin_username,
82+
fullname=config.admin_fullname,
83+
role=ADMIN_ROLE,
84+
)
85+
users_created += admin_result.users_created
86+
roles_assigned += admin_result.roles_assigned
87+
88+
if config.should_seed_development_users:
89+
for development_user in _development_users(config.development_users_password):
90+
result = await _seed_one_user(
91+
user_repository=user_repository,
92+
authorization_service=authorization_service,
93+
email=development_user.email,
94+
password=development_user.password,
95+
username=development_user.username,
96+
fullname=development_user.fullname,
97+
role=development_user.role,
98+
)
99+
users_created += result.users_created
100+
roles_assigned += result.roles_assigned
101+
102+
return UserSeedResult(users_created=users_created, roles_assigned=roles_assigned)
103+
56104

57-
existing = await user_repository.get_by_email(config.admin_email)
105+
@dataclass(frozen=True)
106+
class DevelopmentSeedUser:
107+
email: str
108+
password: str
109+
username: str
110+
fullname: str
111+
role: str
112+
113+
114+
def _development_users(password: str) -> tuple[DevelopmentSeedUser, ...]:
115+
return (
116+
DevelopmentSeedUser(
117+
email="user@example.com",
118+
password=password,
119+
username="user",
120+
fullname="Default User",
121+
role=DEFAULT_USER_ROLE,
122+
),
123+
DevelopmentSeedUser(
124+
email="manager@example.com",
125+
password=password,
126+
username="manager",
127+
fullname="Todo Manager",
128+
role=MANAGER_ROLE,
129+
),
130+
DevelopmentSeedUser(
131+
email="viewer@example.com",
132+
password=password,
133+
username="viewer",
134+
fullname="Todo Viewer",
135+
role=VIEWER_ROLE,
136+
),
137+
)
138+
139+
140+
async def _seed_one_user(
141+
user_repository: SeedUserRepository,
142+
authorization_service: SeedAuthorizationService,
143+
email: str,
144+
password: str,
145+
username: str | None,
146+
fullname: str | None,
147+
role: str,
148+
) -> UserSeedResult:
149+
existing = await user_repository.get_by_email(email)
58150
if existing is not None:
59151
return UserSeedResult()
60152

61153
user = User.create(
62-
email=config.admin_email,
63-
password=PasswordSerrvice.hash(config.admin_password),
64-
username=config.admin_username,
65-
fullname=config.admin_fullname,
154+
email=email,
155+
password=PasswordSerrvice.hash(password),
156+
username=username,
157+
fullname=fullname,
66158
)
67159
saved_user = await user_repository.save(user)
68-
await authorization_service.assign_role(str(saved_user.id), ADMIN_ROLE)
160+
await authorization_service.assign_role(str(saved_user.id), role)
69161

70162
return UserSeedResult(users_created=1, roles_assigned=1)

0 commit comments

Comments
 (0)