&limit=10
+GET /api/v1/todos/{todo_id}
PATCH /api/v1/todos/{todo_id}
DELETE /api/v1/todos/{todo_id}
POST /api/v1/roles/
@@ -240,6 +247,7 @@ Public routes:
- `/openapi.json`
- `/api/v1/auth/login`
- `/api/v1/auth/register`
+- `/api/v1/auth/refresh`
Protected routes require:
@@ -254,6 +262,8 @@ Swagger UI, ReDoc, and OpenAPI JSON are disabled when `APP_ENV=production`.
- Python `3.14` or compatible with the project constraint.
- Poetry.
- PostgreSQL, either local or via Docker.
+- Redis, either local or via Docker.
+- Docker with the Compose plugin, if using the containerized stack.
- Make, if using the generated `Makefile`.
## Environment Variables
@@ -269,6 +279,7 @@ Expected values:
```env
APP_NAME=Todo Modulith API
APP_ENV=production
+FRONTEND_URL=http://localhost:3000
POSTGRES_USER=postgres
POSTGRES_PASSWORD=
POSTGRES_DB=todo_db
@@ -296,6 +307,19 @@ ACCOUNT_LOCKOUT_MAX_ATTEMPTS=5
ACCOUNT_LOCKOUT_WINDOW_MINUTES=15
ACCOUNT_LOCKOUT_DURATION_MINUTES=15
LOG_FORMAT=json
+EMAIL_PROVIDER=ses
+AWS_REGION=us-east-1
+AWS_ACCESS_KEY_ID=
+AWS_SECRET_ACCESS_KEY=
+SES_FROM_EMAIL=noreply@example.com
+SENDGRID_API_KEY=
+SENDGRID_FROM_EMAIL=noreply@example.com
+SMTP_HOST=
+SMTP_PORT=587
+SMTP_USERNAME=
+SMTP_PASSWORD=
+SMTP_FROM_EMAIL=noreply@example.com
+SMTP_USE_TLS=true
SEED_ADMIN_EMAIL=
SEED_ADMIN_PASSWORD=
SEED_ADMIN_USERNAME=admin
@@ -303,27 +327,28 @@ SEED_ADMIN_FULLNAME=System Administrator
SEED_DEVELOPMENT_USERS_PASSWORD=
```
-For local development without Docker, point `DATABASE_URL` at your local PostgreSQL host, for example:
+`MAX_REQUEST_SIZE_MB` is currently interpreted as a byte count despite its name. Keep it at `5242880` for a 5 MiB limit.
+
+For local development without Docker, use development mode and point the service URLs at local PostgreSQL and Redis instances, for example:
```env
+APP_ENV=development
DATABASE_URL=postgresql+asyncpg://postgres@localhost:5432/todo_db
+REDIS_URL=redis://127.0.0.1:6379/0
```
+Production mode requires a non-default `SECRET_KEY`, non-empty database and Redis URLs, JWT issuer and audience values, positive token lifetimes, and explicit CORS origins.
+
## Local Setup
-Install dependencies:
+The Makefile expects Poetry to create `.venv` inside the repository. Configure that once, then install dependencies:
```bash
+poetry config virtualenvs.in-project true --local
poetry install
```
-Activate the virtual environment if desired:
-
-```bash
-poetry shell
-```
-
-Or run commands through Poetry:
+Run commands through Poetry directly:
```bash
poetry run pytest -q
@@ -351,6 +376,8 @@ Open:
http://localhost:8000/docs
```
+This documentation endpoint is available only when `APP_ENV` is not `production`.
+
Health check:
```text
@@ -401,13 +428,13 @@ make downgrade
Important: migration autogeneration depends on importing all SQLAlchemy models in `alembic/env.py`, so new module models must be imported there or through a central model registry.
-Seed baseline authorization data after applying migrations:
+Seed baseline records after applying migrations:
```bash
make seed
```
-The seeder is idempotent. It creates default authorization resources, the default `admin` and `user` roles, default permissions, role-permission links, and matching Casbin policies without duplicating existing records.
+The seeder runs all changes in one transaction and is idempotent. It creates the default authorization resources, roles (`admin`, `user`, `manager`, and `viewer`), permissions, role-permission links, and matching Casbin policies without duplicating existing records. If any seed operation fails, the transaction is rolled back.
To seed an initial admin user, set these environment variables before running `make seed`:
@@ -418,7 +445,17 @@ SEED_ADMIN_USERNAME=admin
SEED_ADMIN_FULLNAME=System Administrator
```
-If `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PASSWORD` is empty, user seeding is skipped. Existing users are not modified.
+For each new seeded user, the repository creates records that follow the normalized user schema:
+
+- `users` stores email, username, password hash, authentication provider, and status.
+- `user_profiles` stores `SEED_ADMIN_FULLNAME` (or the demo account name) as `display_name`.
+- `user_settings` stores the default language, timezone, theme, and notification preferences.
+- `user_security` stores the default login-attempt, lockout, password, and two-factor state.
+- `user_has_roles` associates the user with its seeded role, with a matching Casbin grouping policy.
+
+`SEED_ADMIN_FULLNAME` is retained for configuration compatibility; it does not refer to a `users.fullname` column. The normalized schema stores this value in `user_profiles.display_name`.
+
+If `SEED_ADMIN_EMAIL` or `SEED_ADMIN_PASSWORD` is empty, admin-user seeding is skipped. If a seeded email already exists, the seeder does not change that user's identity, password, profile, settings, security state, or roles.
When `APP_ENV=development`, the seeder can also create demo users with different roles. Set a shared development password before running `make seed`:
@@ -428,11 +465,11 @@ SEED_DEVELOPMENT_USERS_PASSWORD=
Development demo accounts:
-- `user@example.com` with the `user` role
-- `manager@example.com` with the `manager` role
-- `viewer@example.com` with the `viewer` role
+- `user@example.com` with display name `Default User` and the `user` role
+- `manager@example.com` with display name `Todo Manager` and the `manager` role
+- `viewer@example.com` with display name `Todo Viewer` and the `viewer` role
-These users are skipped outside development and are not updated if they already exist.
+Demo users are skipped outside development and are not modified when their email already exists.
## Testing and Quality Checks
@@ -458,8 +495,11 @@ Current check set:
- `pytest -q`
- `ruff check src tests scripts`
+- import boundary checks through `lint-imports`
- import check for `src.main`
+The current pytest suite focuses on application-layer command and query validation. Broader middleware, API integration, and infrastructure regression coverage still needs to be added.
+
Dependency scanning is available separately:
```bash
@@ -474,6 +514,7 @@ make install
make run
make test
make lint
+make lint-imports
make import-check
make security-scan
make check
@@ -487,6 +528,10 @@ make db-logs
make clean
```
+## Additional Documentation
+
+- [Query Optimization Guide](docs/QUERY_OPTIMIZATION.md)
+
## Docker Notes
Before starting Docker Compose, set non-empty `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, and `SECRET_KEY` in `.env`. Compose intentionally fails fast when database or Redis passwords are missing.
@@ -497,6 +542,8 @@ Run API, PostgreSQL, and Redis services:
make db-up
```
+The API container applies Alembic migrations before starting Uvicorn. Database seeding remains an explicit `make seed` step.
+
Stop services:
```bash
@@ -635,11 +682,11 @@ Legend: `Implemented` means code exists in the repository. `Partial` means code
- [x] Add production config validation for secrets and unsafe defaults.
- [x] Harden CORS through environment-driven allowed origins, methods, and headers.
- [x] Review exception responses to avoid leaking token parsing details or internal exception messages.
-- [x] Add automated tests for request size limits, rate limiting, auth failures, authorization failures, CORS, security headers, and request IDs.
+- [ ] Add automated tests for request size limits, rate limiting, auth failures, authorization failures, CORS, security headers, and request IDs.
- [x] Add dependency vulnerability scanning to local or CI checks, for example `pip-audit` or an equivalent Poetry-compatible scanner.
## Known Notes
-- `src/core/lifespan.py` still calls `Base.metadata.create_all`; with Alembic in place, production environments normally rely on migrations instead.
-- The project has a Pydantic v2 deprecation warning for class-based settings config.
-- The current architecture is clean enough for a learning modulith, but some flows can be made stricter by moving remaining business orchestration out of routers and into application handlers.
+- The automated test suite currently covers application validation only; middleware, router, database, Redis, and authorization integration paths are not covered.
+- Authorization persistence currently exists under both `src/core/authorization/infrastructure` and `src/modules/authorization/infrastructure`; new work should avoid increasing that duplication.
+- Some authorization routes call the domain service directly while other modules use dedicated application handlers, so CQRS boundaries are not yet applied consistently.
diff --git a/alembic/env.py b/alembic/env.py
index 87a7004..b768594 100644
--- a/alembic/env.py
+++ b/alembic/env.py
@@ -7,39 +7,36 @@
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
-from src.core.authorization.infrastructure.models.casbin_rule_model import (
+from src.core.config.setting import get_settings
+from src.core.security.infrastructure.models.audit_log_model import (
+ AuditLogModel, # noqa: F401
+)
+from src.core.security.infrastructure.models.error_trace_model import (
+ ErrorTraceModel, # noqa: F401
+)
+from src.core.security.infrastructure.models.login_attempt_model import (
+ LoginAttemptModel, # noqa: F401
+)
+from src.modules.authorization.infrastructure.models.casbin_rule_model import (
CasbinRuleModel, # noqa: F401
)
-from src.core.authorization.infrastructure.models.permission_model import (
+from src.modules.authorization.infrastructure.models.permission_model import (
PermissionModel, # noqa: F401
)
-from src.core.authorization.infrastructure.models.resource_model import (
+from src.modules.authorization.infrastructure.models.resource_model import (
AuthorizationResourceModel, # noqa: F401
)
-from src.core.authorization.infrastructure.models.role_model import (
+from src.modules.authorization.infrastructure.models.role_model import (
RoleModel, # noqa: F401
)
-from src.core.authorization.infrastructure.models.role_permission_model import (
+from src.modules.authorization.infrastructure.models.role_permission_model import (
RolePermissionModel, # noqa: F401
)
-from src.core.authorization.infrastructure.models.user_has_role_model import (
+from src.modules.authorization.infrastructure.models.user_has_role_model import (
UserHasRoleModel, # noqa: F401
)
-from src.core.config.setting import get_settings
-from src.core.security.infrastructure.models.audit_log_model import (
- AuditLogModel, # noqa: F401
-)
-from src.core.security.infrastructure.models.error_trace_model import (
- ErrorTraceModel, # noqa: F401
-)
-from src.core.security.infrastructure.models.login_attempt_model import (
- LoginAttemptModel, # noqa: F401
-)
from src.modules.todo.infrastructure.models.todo_model import TodoModel # noqa: F401
-from src.modules.user.infrastructure.models.refresh_token_model import (
- RefreshTokenModel, # noqa: F401
-)
-from src.modules.user.infrastructure.models.user_model import UserModel # noqa: F401
+from src.modules.user.infrastructure import models as user_models # noqa: F401
from src.shared.database.model import Base
settings = get_settings()
diff --git a/alembic/versions/3be03348cbd4_initial_commit.py b/alembic/versions/3be03348cbd4_initial_commit.py
deleted file mode 100644
index 58e9d0d..0000000
--- a/alembic/versions/3be03348cbd4_initial_commit.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""initial commit
-
-Revision ID: 3be03348cbd4
-Revises:
-Create Date: 2026-06-17 11:43:46.925487
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = '3be03348cbd4'
-down_revision: Union[str, Sequence[str], None] = None
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Upgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.create_table('casbin_rules',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('ptype', sa.String(length=16), nullable=False),
- sa.Column('v0', sa.String(length=255), nullable=True),
- sa.Column('v1', sa.String(length=255), nullable=True),
- sa.Column('v2', sa.String(length=255), nullable=True),
- sa.Column('v3', sa.String(length=255), nullable=True),
- sa.Column('v4', sa.String(length=255), nullable=True),
- sa.Column('v5', sa.String(length=255), nullable=True),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_index(op.f('ix_casbin_rules_ptype'), 'casbin_rules', ['ptype'], unique=False)
- op.create_index(op.f('ix_casbin_rules_v0'), 'casbin_rules', ['v0'], unique=False)
- op.create_index(op.f('ix_casbin_rules_v1'), 'casbin_rules', ['v1'], unique=False)
- op.create_index(op.f('ix_casbin_rules_v2'), 'casbin_rules', ['v2'], unique=False)
- op.create_table('permissions',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('deleted_at', sa.DateTime(), nullable=True),
- sa.Column('key', sa.String(length=255), nullable=False),
- sa.Column('resource', sa.String(length=100), nullable=False),
- sa.Column('action', sa.String(length=100), nullable=False),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('resource', 'action', name='uq_permissions_resource_action')
- )
- op.create_index(op.f('ix_permissions_action'), 'permissions', ['action'], unique=False)
- op.create_index(op.f('ix_permissions_key'), 'permissions', ['key'], unique=True)
- op.create_index(op.f('ix_permissions_resource'), 'permissions', ['resource'], unique=False)
- op.create_table('roles',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('deleted_at', sa.DateTime(), nullable=True),
- sa.Column('name', sa.String(length=100), nullable=False),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_index(op.f('ix_roles_name'), 'roles', ['name'], unique=True)
- op.create_table('users',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('deleted_at', sa.DateTime(), nullable=True),
- sa.Column('username', sa.String(length=255), nullable=True),
- sa.Column('fullname', sa.String(length=255), nullable=True),
- sa.Column('birthday', sa.Date(), nullable=True),
- sa.Column('email', sa.String(length=255), nullable=False),
- sa.Column('password', sa.String(length=255), nullable=False),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
- op.create_table('refresh_tokens',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('deleted_at', sa.DateTime(), nullable=True),
- sa.Column('user_id', sa.Uuid(), nullable=False),
- sa.Column('token_hash', sa.String(length=255), nullable=False),
- sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
- sa.Column('is_revoked', sa.Boolean(), nullable=False),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True)
- op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False)
- op.create_table('role_permissions',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('role_id', sa.Uuid(), nullable=False),
- sa.Column('permission_id', sa.Uuid(), nullable=False),
- sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ),
- sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('role_id', 'permission_id', name='uq_role_permissions_role_id_permission_id')
- )
- op.create_index(op.f('ix_role_permissions_permission_id'), 'role_permissions', ['permission_id'], unique=False)
- op.create_index(op.f('ix_role_permissions_role_id'), 'role_permissions', ['role_id'], unique=False)
- op.create_table('todos',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
- sa.Column('deleted_at', sa.DateTime(), nullable=True),
- sa.Column('title', sa.String(length=255), nullable=False),
- sa.Column('description', sa.String(length=500), nullable=True),
- sa.Column('is_completed', sa.Boolean(), nullable=False),
- sa.Column('user_id', sa.Uuid(), nullable=False),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
- sa.PrimaryKeyConstraint('id')
- )
- op.create_table('user_has_roles',
- sa.Column('id', sa.Uuid(), nullable=False),
- sa.Column('user_id', sa.Uuid(), nullable=False),
- sa.Column('role_id', sa.Uuid(), nullable=False),
- sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
- sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
- sa.PrimaryKeyConstraint('id'),
- sa.UniqueConstraint('user_id', 'role_id', name='uq_user_has_roles_user_id_role_id')
- )
- op.create_index(op.f('ix_user_has_roles_role_id'), 'user_has_roles', ['role_id'], unique=False)
- op.create_index(op.f('ix_user_has_roles_user_id'), 'user_has_roles', ['user_id'], unique=False)
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_index(op.f('ix_user_has_roles_user_id'), table_name='user_has_roles')
- op.drop_index(op.f('ix_user_has_roles_role_id'), table_name='user_has_roles')
- op.drop_table('user_has_roles')
- op.drop_table('todos')
- op.drop_index(op.f('ix_role_permissions_role_id'), table_name='role_permissions')
- op.drop_index(op.f('ix_role_permissions_permission_id'), table_name='role_permissions')
- op.drop_table('role_permissions')
- op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens')
- op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens')
- op.drop_table('refresh_tokens')
- op.drop_index(op.f('ix_users_email'), table_name='users')
- op.drop_table('users')
- op.drop_index(op.f('ix_roles_name'), table_name='roles')
- op.drop_table('roles')
- op.drop_index(op.f('ix_permissions_resource'), table_name='permissions')
- op.drop_index(op.f('ix_permissions_key'), table_name='permissions')
- op.drop_index(op.f('ix_permissions_action'), table_name='permissions')
- op.drop_table('permissions')
- op.drop_index(op.f('ix_casbin_rules_v2'), table_name='casbin_rules')
- op.drop_index(op.f('ix_casbin_rules_v1'), table_name='casbin_rules')
- op.drop_index(op.f('ix_casbin_rules_v0'), table_name='casbin_rules')
- op.drop_index(op.f('ix_casbin_rules_ptype'), table_name='casbin_rules')
- op.drop_table('casbin_rules')
- # ### end Alembic commands ###
diff --git a/alembic/versions/aa90557ef712_add_description_col_to_permission_and_.py b/alembic/versions/aa90557ef712_add_description_col_to_permission_and_.py
deleted file mode 100644
index a0eeca8..0000000
--- a/alembic/versions/aa90557ef712_add_description_col_to_permission_and_.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""add description col to permission and role
-
-Revision ID: aa90557ef712
-Revises: 3be03348cbd4
-Create Date: 2026-06-17 11:46:37.274806
-
-"""
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-# revision identifiers, used by Alembic.
-revision: str = 'aa90557ef712'
-down_revision: Union[str, Sequence[str], None] = '3be03348cbd4'
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Upgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.add_column('permissions', sa.Column('descpription', sa.String(length=255), nullable=True))
- op.add_column('roles', sa.Column('descpription', sa.String(length=255), nullable=True))
- # ### end Alembic commands ###
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- # ### commands auto generated by Alembic - please adjust! ###
- op.drop_column('roles', 'descpription')
- op.drop_column('permissions', 'descpription')
- # ### end Alembic commands ###
diff --git a/alembic/versions/b0de87aaeb97_initial_schemas.py b/alembic/versions/b0de87aaeb97_initial_schemas.py
new file mode 100644
index 0000000..de2e048
--- /dev/null
+++ b/alembic/versions/b0de87aaeb97_initial_schemas.py
@@ -0,0 +1,378 @@
+"""initial schemas
+
+Revision ID: b0de87aaeb97
+Revises:
+Create Date: 2026-06-22 21:27:53.122592
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = 'b0de87aaeb97'
+down_revision: Union[str, Sequence[str], None] = None
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.create_table('audit_logs',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('action', sa.String(length=120), nullable=False),
+ sa.Column('actor_id', sa.String(length=64), nullable=True),
+ sa.Column('resource_type', sa.String(length=80), nullable=True),
+ sa.Column('resource_id', sa.String(length=64), nullable=True),
+ sa.Column('request_id', sa.String(length=120), nullable=True),
+ sa.Column('meta', sa.JSON(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_audit_logs_action'), 'audit_logs', ['action'], unique=False)
+ op.create_index(op.f('ix_audit_logs_actor_id'), 'audit_logs', ['actor_id'], unique=False)
+ op.create_index(op.f('ix_audit_logs_created_at'), 'audit_logs', ['created_at'], unique=False)
+ op.create_index(op.f('ix_audit_logs_request_id'), 'audit_logs', ['request_id'], unique=False)
+ op.create_table('authorization_resources',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('key', sa.String(length=100), nullable=False),
+ sa.Column('name', sa.String(length=150), nullable=False),
+ sa.Column('description', sa.String(length=255), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('key')
+ )
+ op.create_index('ix_authorization_resources_key', 'authorization_resources', ['key'], unique=True)
+ op.create_table('casbin_rules',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('ptype', sa.String(length=16), nullable=False),
+ sa.Column('v0', sa.String(length=255), nullable=True),
+ sa.Column('v1', sa.String(length=255), nullable=True),
+ sa.Column('v2', sa.String(length=255), nullable=True),
+ sa.Column('v3', sa.String(length=255), nullable=True),
+ sa.Column('v4', sa.String(length=255), nullable=True),
+ sa.Column('v5', sa.String(length=255), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_casbin_rules_ptype'), 'casbin_rules', ['ptype'], unique=False)
+ op.create_index(op.f('ix_casbin_rules_v0'), 'casbin_rules', ['v0'], unique=False)
+ op.create_index(op.f('ix_casbin_rules_v1'), 'casbin_rules', ['v1'], unique=False)
+ op.create_index(op.f('ix_casbin_rules_v2'), 'casbin_rules', ['v2'], unique=False)
+ op.create_table('error_traces',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('error_type', sa.String(length=120), nullable=False),
+ sa.Column('message', sa.Text(), nullable=False),
+ sa.Column('traceback', sa.Text(), nullable=False),
+ sa.Column('method', sa.String(length=12), nullable=False),
+ sa.Column('path', sa.String(length=500), nullable=False),
+ sa.Column('actor_id', sa.String(length=64), nullable=True),
+ sa.Column('request_id', sa.String(length=120), nullable=True),
+ sa.Column('meta', sa.JSON(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_error_traces_actor_id'), 'error_traces', ['actor_id'], unique=False)
+ op.create_index(op.f('ix_error_traces_created_at'), 'error_traces', ['created_at'], unique=False)
+ op.create_index(op.f('ix_error_traces_error_type'), 'error_traces', ['error_type'], unique=False)
+ op.create_index(op.f('ix_error_traces_path'), 'error_traces', ['path'], unique=False)
+ op.create_index(op.f('ix_error_traces_request_id'), 'error_traces', ['request_id'], unique=False)
+ op.create_table('login_attempts',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('email', sa.String(length=255), nullable=False),
+ sa.Column('occurred_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index(op.f('ix_login_attempts_email'), 'login_attempts', ['email'], unique=False)
+ op.create_index(op.f('ix_login_attempts_locked_until'), 'login_attempts', ['locked_until'], unique=False)
+ op.create_index(op.f('ix_login_attempts_occurred_at'), 'login_attempts', ['occurred_at'], unique=False)
+ op.create_table('roles',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('name', sa.String(length=100), nullable=False),
+ sa.Column('description', sa.String(length=255), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('name')
+ )
+ op.create_index('ix_roles_name', 'roles', ['name'], unique=True)
+ op.create_table('users',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('email', sa.String(length=255), nullable=False),
+ sa.Column('username', sa.String(length=100), nullable=True),
+ sa.Column('password_hash', sa.String(length=255), nullable=True),
+ sa.Column('auth_provider', sa.String(length=50), nullable=False),
+ sa.Column('external_id', sa.String(length=255), nullable=True),
+ sa.Column('status', sa.String(length=50), nullable=False),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('ix_users_auth_provider', 'users', ['auth_provider'], unique=False)
+ op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
+ op.create_index('ix_users_status', 'users', ['status'], unique=False)
+ op.create_index(op.f('ix_users_username'), 'users', ['username'], unique=True)
+ op.create_table('permissions',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('key', sa.String(length=255), nullable=False),
+ sa.Column('resource_id', sa.UUID(), nullable=False),
+ sa.Column('resource', sa.String(length=100), nullable=False),
+ sa.Column('action', sa.String(length=100), nullable=False),
+ sa.Column('description', sa.String(length=255), nullable=True),
+ sa.ForeignKeyConstraint(['resource_id'], ['authorization_resources.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('key'),
+ sa.UniqueConstraint('resource', 'action', name='uq_permissions_resource_action')
+ )
+ op.create_index('ix_permissions_action', 'permissions', ['action'], unique=False)
+ op.create_index('ix_permissions_key', 'permissions', ['key'], unique=True)
+ op.create_index('ix_permissions_resource', 'permissions', ['resource'], unique=False)
+ op.create_index('ix_permissions_resource_id', 'permissions', ['resource_id'], unique=False)
+ op.create_table('todos',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('title', sa.String(length=255), nullable=False),
+ sa.Column('description', sa.String(length=500), nullable=True),
+ sa.Column('is_completed', sa.Boolean(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_table('user_addresses',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('label', sa.String(length=100), nullable=False),
+ sa.Column('line1', sa.String(length=255), nullable=False),
+ sa.Column('line2', sa.String(length=255), nullable=True),
+ sa.Column('line3', sa.String(length=255), nullable=True),
+ sa.Column('city', sa.String(length=100), nullable=False),
+ sa.Column('state', sa.String(length=100), nullable=True),
+ sa.Column('postal_code', sa.String(length=20), nullable=False),
+ sa.Column('country', sa.String(length=2), nullable=False),
+ sa.Column('is_default', sa.Boolean(), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('ix_user_addresses_country', 'user_addresses', ['country'], unique=False)
+ op.create_index('ix_user_addresses_is_default', 'user_addresses', ['is_default'], unique=False)
+ op.create_index('ix_user_addresses_label', 'user_addresses', ['label'], unique=False)
+ op.create_index('ix_user_addresses_user_id', 'user_addresses', ['user_id'], unique=False)
+ op.create_table('user_contacts',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('contact_type', sa.String(length=50), nullable=False),
+ sa.Column('value', sa.String(length=255), nullable=False),
+ sa.Column('is_primary', sa.Boolean(), nullable=False),
+ sa.Column('is_verified', sa.Boolean(), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('ix_user_contacts_is_primary', 'user_contacts', ['is_primary'], unique=False)
+ op.create_index('ix_user_contacts_type', 'user_contacts', ['contact_type'], unique=False)
+ op.create_index('ix_user_contacts_user_id', 'user_contacts', ['user_id'], unique=False)
+ op.create_table('user_has_roles',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('role_id', sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('user_id', 'role_id', name='uq_user_has_roles_user_id_role_id')
+ )
+ op.create_index('ix_user_has_roles_role_id', 'user_has_roles', ['role_id'], unique=False)
+ op.create_index('ix_user_has_roles_user_id', 'user_has_roles', ['user_id'], unique=False)
+ op.create_table('user_profiles',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('first_name', sa.String(length=100), nullable=True),
+ sa.Column('last_name', sa.String(length=100), nullable=True),
+ sa.Column('display_name', sa.String(length=255), nullable=True),
+ sa.Column('avatar_url', sa.String(length=500), nullable=True),
+ sa.Column('bio', sa.Text(), nullable=True),
+ sa.Column('birth_date', sa.Date(), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('user_id')
+ )
+ op.create_index('ix_user_profiles_user_id', 'user_profiles', ['user_id'], unique=True)
+ op.create_table('user_security',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('failed_login_attempts', sa.Integer(), nullable=False),
+ sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('password_changed_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('two_factor_enabled', sa.Boolean(), nullable=False),
+ sa.Column('two_factor_secret', sa.String(length=255), nullable=True),
+ sa.Column('two_factor_backup_codes', sa.String(length=1000), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('user_id')
+ )
+ op.create_index('ix_user_security_locked_until', 'user_security', ['locked_until'], unique=False)
+ op.create_index('ix_user_security_two_factor_enabled', 'user_security', ['two_factor_enabled'], unique=False)
+ op.create_index('ix_user_security_user_id', 'user_security', ['user_id'], unique=True)
+ op.create_table('user_sessions',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('refresh_token_hash', sa.String(length=255), nullable=False),
+ sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
+ sa.Column('device_info', sa.String(length=500), nullable=True),
+ sa.Column('ip_address', sa.String(length=45), nullable=True),
+ sa.Column('user_agent', sa.String(length=1000), nullable=True),
+ sa.Column('is_revoked', sa.Boolean(), nullable=False),
+ sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('revoked_reason', sa.String(length=255), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('ix_user_sessions_device_info', 'user_sessions', ['device_info'], unique=False)
+ op.create_index('ix_user_sessions_expires_at', 'user_sessions', ['expires_at'], unique=False)
+ op.create_index('ix_user_sessions_is_revoked', 'user_sessions', ['is_revoked'], unique=False)
+ op.create_index('ix_user_sessions_token_hash', 'user_sessions', ['refresh_token_hash'], unique=True)
+ op.create_index('ix_user_sessions_user_id', 'user_sessions', ['user_id'], unique=False)
+ op.create_table('user_settings',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('preferences', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('user_id')
+ )
+ op.create_index('ix_user_settings_preferences', 'user_settings', ['preferences'], unique=False, postgresql_using='gin')
+ op.create_index('ix_user_settings_user_id', 'user_settings', ['user_id'], unique=True)
+ op.create_table('user_verifications',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
+ sa.Column('deleted_at', sa.DateTime(), nullable=True),
+ sa.Column('user_id', sa.UUID(), nullable=False),
+ sa.Column('channel', sa.String(length=50), nullable=False),
+ sa.Column('is_verified', sa.Boolean(), nullable=False),
+ sa.Column('verified_at', sa.DateTime(timezone=True), nullable=True),
+ sa.Column('verification_token', sa.String(length=255), nullable=True),
+ sa.Column('token_expires_at', sa.DateTime(timezone=True), nullable=True),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ op.create_index('ix_user_verifications_channel', 'user_verifications', ['channel'], unique=False)
+ op.create_index('ix_user_verifications_is_verified', 'user_verifications', ['is_verified'], unique=False)
+ op.create_index('ix_user_verifications_token', 'user_verifications', ['verification_token'], unique=False)
+ op.create_index('ix_user_verifications_user_id', 'user_verifications', ['user_id'], unique=False)
+ op.create_table('role_permissions',
+ sa.Column('id', sa.UUID(), nullable=False),
+ sa.Column('role_id', sa.UUID(), nullable=False),
+ sa.Column('permission_id', sa.UUID(), nullable=False),
+ sa.ForeignKeyConstraint(['permission_id'], ['permissions.id'], ),
+ sa.ForeignKeyConstraint(['role_id'], ['roles.id'], ),
+ sa.PrimaryKeyConstraint('id'),
+ sa.UniqueConstraint('role_id', 'permission_id', name='uq_role_permissions_role_id_permission_id')
+ )
+ op.create_index('ix_role_permissions_permission_id', 'role_permissions', ['permission_id'], unique=False)
+ op.create_index('ix_role_permissions_role_id', 'role_permissions', ['role_id'], unique=False)
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_index('ix_role_permissions_role_id', table_name='role_permissions')
+ op.drop_index('ix_role_permissions_permission_id', table_name='role_permissions')
+ op.drop_table('role_permissions')
+ op.drop_index('ix_user_verifications_user_id', table_name='user_verifications')
+ op.drop_index('ix_user_verifications_token', table_name='user_verifications')
+ op.drop_index('ix_user_verifications_is_verified', table_name='user_verifications')
+ op.drop_index('ix_user_verifications_channel', table_name='user_verifications')
+ op.drop_table('user_verifications')
+ op.drop_index('ix_user_settings_user_id', table_name='user_settings')
+ op.drop_index('ix_user_settings_preferences', table_name='user_settings', postgresql_using='gin')
+ op.drop_table('user_settings')
+ op.drop_index('ix_user_sessions_user_id', table_name='user_sessions')
+ op.drop_index('ix_user_sessions_token_hash', table_name='user_sessions')
+ op.drop_index('ix_user_sessions_is_revoked', table_name='user_sessions')
+ op.drop_index('ix_user_sessions_expires_at', table_name='user_sessions')
+ op.drop_index('ix_user_sessions_device_info', table_name='user_sessions')
+ op.drop_table('user_sessions')
+ op.drop_index('ix_user_security_user_id', table_name='user_security')
+ op.drop_index('ix_user_security_two_factor_enabled', table_name='user_security')
+ op.drop_index('ix_user_security_locked_until', table_name='user_security')
+ op.drop_table('user_security')
+ op.drop_index('ix_user_profiles_user_id', table_name='user_profiles')
+ op.drop_table('user_profiles')
+ op.drop_index('ix_user_has_roles_user_id', table_name='user_has_roles')
+ op.drop_index('ix_user_has_roles_role_id', table_name='user_has_roles')
+ op.drop_table('user_has_roles')
+ op.drop_index('ix_user_contacts_user_id', table_name='user_contacts')
+ op.drop_index('ix_user_contacts_type', table_name='user_contacts')
+ op.drop_index('ix_user_contacts_is_primary', table_name='user_contacts')
+ op.drop_table('user_contacts')
+ op.drop_index('ix_user_addresses_user_id', table_name='user_addresses')
+ op.drop_index('ix_user_addresses_label', table_name='user_addresses')
+ op.drop_index('ix_user_addresses_is_default', table_name='user_addresses')
+ op.drop_index('ix_user_addresses_country', table_name='user_addresses')
+ op.drop_table('user_addresses')
+ op.drop_table('todos')
+ op.drop_index('ix_permissions_resource_id', table_name='permissions')
+ op.drop_index('ix_permissions_resource', table_name='permissions')
+ op.drop_index('ix_permissions_key', table_name='permissions')
+ op.drop_index('ix_permissions_action', table_name='permissions')
+ op.drop_table('permissions')
+ op.drop_index(op.f('ix_users_username'), table_name='users')
+ op.drop_index('ix_users_status', table_name='users')
+ op.drop_index(op.f('ix_users_email'), table_name='users')
+ op.drop_index('ix_users_auth_provider', table_name='users')
+ op.drop_table('users')
+ op.drop_index('ix_roles_name', table_name='roles')
+ op.drop_table('roles')
+ op.drop_index(op.f('ix_login_attempts_occurred_at'), table_name='login_attempts')
+ op.drop_index(op.f('ix_login_attempts_locked_until'), table_name='login_attempts')
+ op.drop_index(op.f('ix_login_attempts_email'), table_name='login_attempts')
+ op.drop_table('login_attempts')
+ op.drop_index(op.f('ix_error_traces_request_id'), table_name='error_traces')
+ op.drop_index(op.f('ix_error_traces_path'), table_name='error_traces')
+ op.drop_index(op.f('ix_error_traces_error_type'), table_name='error_traces')
+ op.drop_index(op.f('ix_error_traces_created_at'), table_name='error_traces')
+ op.drop_index(op.f('ix_error_traces_actor_id'), table_name='error_traces')
+ op.drop_table('error_traces')
+ op.drop_index(op.f('ix_casbin_rules_v2'), table_name='casbin_rules')
+ op.drop_index(op.f('ix_casbin_rules_v1'), table_name='casbin_rules')
+ op.drop_index(op.f('ix_casbin_rules_v0'), table_name='casbin_rules')
+ op.drop_index(op.f('ix_casbin_rules_ptype'), table_name='casbin_rules')
+ op.drop_table('casbin_rules')
+ op.drop_index('ix_authorization_resources_key', table_name='authorization_resources')
+ op.drop_table('authorization_resources')
+ op.drop_index(op.f('ix_audit_logs_request_id'), table_name='audit_logs')
+ op.drop_index(op.f('ix_audit_logs_created_at'), table_name='audit_logs')
+ op.drop_index(op.f('ix_audit_logs_actor_id'), table_name='audit_logs')
+ op.drop_index(op.f('ix_audit_logs_action'), table_name='audit_logs')
+ op.drop_table('audit_logs')
+ # ### end Alembic commands ###
diff --git a/alembic/versions/b2f4c7d9a1e0_add_security_audit_and_login_attempts.py b/alembic/versions/b2f4c7d9a1e0_add_security_audit_and_login_attempts.py
deleted file mode 100644
index f130ae6..0000000
--- a/alembic/versions/b2f4c7d9a1e0_add_security_audit_and_login_attempts.py
+++ /dev/null
@@ -1,111 +0,0 @@
-"""add security audit and login attempts
-
-Revision ID: b2f4c7d9a1e0
-Revises: aa90557ef712
-Create Date: 2026-06-19 00:00:00.000000
-
-"""
-
-from typing import Sequence, Union
-
-from alembic import op
-import sqlalchemy as sa
-
-
-revision: str = "b2f4c7d9a1e0"
-down_revision: Union[str, Sequence[str], None] = "aa90557ef712"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- op.create_table(
- "audit_logs",
- sa.Column("action", sa.String(length=120), nullable=False),
- sa.Column("actor_id", sa.String(length=64), nullable=True),
- sa.Column("resource_type", sa.String(length=80), nullable=True),
- sa.Column("resource_id", sa.String(length=64), nullable=True),
- sa.Column("request_id", sa.String(length=120), nullable=True),
- sa.Column("meta", sa.JSON(), nullable=False),
- sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
- sa.Column("id", sa.Uuid(), nullable=False),
- sa.PrimaryKeyConstraint("id"),
- )
- op.create_index("ix_audit_logs_action", "audit_logs", ["action"], unique=False)
- op.create_index("ix_audit_logs_actor_id", "audit_logs", ["actor_id"], unique=False)
- op.create_index(
- "ix_audit_logs_created_at", "audit_logs", ["created_at"], unique=False
- )
- op.create_index(
- "ix_audit_logs_request_id", "audit_logs", ["request_id"], unique=False
- )
-
- op.create_table(
- "login_attempts",
- sa.Column("email", sa.String(length=255), nullable=False),
- sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
- sa.Column("locked_until", sa.DateTime(timezone=True), nullable=True),
- sa.Column("id", sa.Uuid(), nullable=False),
- sa.PrimaryKeyConstraint("id"),
- )
- op.create_index("ix_login_attempts_email", "login_attempts", ["email"], unique=False)
- op.create_index(
- "ix_login_attempts_locked_until",
- "login_attempts",
- ["locked_until"],
- unique=False,
- )
- op.create_index(
- "ix_login_attempts_occurred_at",
- "login_attempts",
- ["occurred_at"],
- unique=False,
- )
-
- op.create_table(
- "error_traces",
- sa.Column("error_type", sa.String(length=120), nullable=False),
- sa.Column("message", sa.Text(), nullable=False),
- sa.Column("traceback", sa.Text(), nullable=False),
- sa.Column("method", sa.String(length=12), nullable=False),
- sa.Column("path", sa.String(length=500), nullable=False),
- sa.Column("actor_id", sa.String(length=64), nullable=True),
- sa.Column("request_id", sa.String(length=120), nullable=True),
- sa.Column("meta", sa.JSON(), nullable=False),
- sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
- sa.Column("id", sa.Uuid(), nullable=False),
- sa.PrimaryKeyConstraint("id"),
- )
- op.create_index(
- "ix_error_traces_actor_id", "error_traces", ["actor_id"], unique=False
- )
- op.create_index(
- "ix_error_traces_created_at", "error_traces", ["created_at"], unique=False
- )
- op.create_index(
- "ix_error_traces_error_type", "error_traces", ["error_type"], unique=False
- )
- op.create_index("ix_error_traces_path", "error_traces", ["path"], unique=False)
- op.create_index(
- "ix_error_traces_request_id", "error_traces", ["request_id"], unique=False
- )
-
-
-def downgrade() -> None:
- op.drop_index("ix_error_traces_request_id", table_name="error_traces")
- op.drop_index("ix_error_traces_path", table_name="error_traces")
- op.drop_index("ix_error_traces_error_type", table_name="error_traces")
- op.drop_index("ix_error_traces_created_at", table_name="error_traces")
- op.drop_index("ix_error_traces_actor_id", table_name="error_traces")
- op.drop_table("error_traces")
-
- op.drop_index("ix_login_attempts_occurred_at", table_name="login_attempts")
- op.drop_index("ix_login_attempts_locked_until", table_name="login_attempts")
- op.drop_index("ix_login_attempts_email", table_name="login_attempts")
- op.drop_table("login_attempts")
-
- op.drop_index("ix_audit_logs_request_id", table_name="audit_logs")
- op.drop_index("ix_audit_logs_created_at", table_name="audit_logs")
- op.drop_index("ix_audit_logs_actor_id", table_name="audit_logs")
- op.drop_index("ix_audit_logs_action", table_name="audit_logs")
- op.drop_table("audit_logs")
diff --git a/alembic/versions/c7a1b9e5d4f2_rename_authorization_description_columns.py b/alembic/versions/c7a1b9e5d4f2_rename_authorization_description_columns.py
deleted file mode 100644
index 490460c..0000000
--- a/alembic/versions/c7a1b9e5d4f2_rename_authorization_description_columns.py
+++ /dev/null
@@ -1,34 +0,0 @@
-"""rename authorization description columns
-
-Revision ID: c7a1b9e5d4f2
-Revises: b2f4c7d9a1e0
-Create Date: 2026-06-19 00:00:00.000000
-
-"""
-
-from typing import Sequence, Union
-
-from alembic import op
-
-
-revision: str = "c7a1b9e5d4f2"
-down_revision: Union[str, Sequence[str], None] = "b2f4c7d9a1e0"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def _rename_column(table_name: str, old_name: str, new_name: str) -> None:
- with op.batch_alter_table(table_name) as batch_op:
- batch_op.alter_column(old_name, new_column_name=new_name)
-
-
-def upgrade() -> None:
- """Upgrade schema."""
- _rename_column("permissions", "descpription", "description")
- _rename_column("roles", "descpription", "description")
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- _rename_column("roles", "description", "descpription")
- _rename_column("permissions", "description", "descpription")
diff --git a/alembic/versions/d9a7c3f2b6e1_add_authorization_resources.py b/alembic/versions/d9a7c3f2b6e1_add_authorization_resources.py
deleted file mode 100644
index 6bbb8f5..0000000
--- a/alembic/versions/d9a7c3f2b6e1_add_authorization_resources.py
+++ /dev/null
@@ -1,124 +0,0 @@
-"""add authorization resources
-
-Revision ID: d9a7c3f2b6e1
-Revises: c7a1b9e5d4f2
-Create Date: 2026-06-19 00:00:00.000000
-
-"""
-
-from typing import Sequence, Union
-from uuid import uuid4
-
-from alembic import op
-import sqlalchemy as sa
-
-
-revision: str = "d9a7c3f2b6e1"
-down_revision: Union[str, Sequence[str], None] = "c7a1b9e5d4f2"
-branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
-
-
-def upgrade() -> None:
- """Upgrade schema."""
- op.create_table(
- "authorization_resources",
- sa.Column("id", sa.Uuid(), nullable=False),
- sa.Column(
- "created_at",
- sa.DateTime(timezone=True),
- server_default=sa.text("now()"),
- nullable=False,
- ),
- sa.Column(
- "updated_at",
- sa.DateTime(timezone=True),
- server_default=sa.text("now()"),
- nullable=False,
- ),
- sa.Column("deleted_at", sa.DateTime(), nullable=True),
- sa.Column("key", sa.String(length=100), nullable=False),
- sa.Column("name", sa.String(length=150), nullable=False),
- sa.Column("description", sa.String(length=255), nullable=True),
- sa.PrimaryKeyConstraint("id"),
- )
- op.create_index(
- op.f("ix_authorization_resources_key"),
- "authorization_resources",
- ["key"],
- unique=True,
- )
-
- with op.batch_alter_table("permissions") as batch_op:
- batch_op.add_column(sa.Column("resource_id", sa.Uuid(), nullable=True))
- batch_op.create_index(
- op.f("ix_permissions_resource_id"),
- ["resource_id"],
- unique=False,
- )
-
- bind = op.get_bind()
- resources = [
- row[0]
- for row in bind.execute(
- sa.text("select distinct resource from permissions where resource is not null")
- )
- ]
-
- resource_ids = {}
- for resource in resources:
- resource_id = uuid4()
- resource_ids[resource] = resource_id
- bind.execute(
- sa.text(
- """
- insert into authorization_resources
- (id, key, name, description)
- values
- (:id, :key, :name, :description)
- """
- ),
- {
- "id": resource_id,
- "key": resource,
- "name": resource.replace("_", " ").title(),
- "description": f"{resource} resources",
- },
- )
-
- for resource, resource_id in resource_ids.items():
- bind.execute(
- sa.text(
- """
- update permissions
- set resource_id = :resource_id
- where resource = :resource
- """
- ),
- {"resource_id": resource_id, "resource": resource},
- )
-
- with op.batch_alter_table("permissions") as batch_op:
- batch_op.create_foreign_key(
- "fk_permissions_resource_id_authorization_resources",
- "authorization_resources",
- ["resource_id"],
- ["id"],
- )
-
-
-def downgrade() -> None:
- """Downgrade schema."""
- with op.batch_alter_table("permissions") as batch_op:
- batch_op.drop_constraint(
- "fk_permissions_resource_id_authorization_resources",
- type_="foreignkey",
- )
- batch_op.drop_index(op.f("ix_permissions_resource_id"))
- batch_op.drop_column("resource_id")
-
- op.drop_index(
- op.f("ix_authorization_resources_key"),
- table_name="authorization_resources",
- )
- op.drop_table("authorization_resources")
diff --git a/docs/NORMALIZED_USER_DOMAIN.md b/docs/NORMALIZED_USER_DOMAIN.md
new file mode 100644
index 0000000..cb867c3
--- /dev/null
+++ b/docs/NORMALIZED_USER_DOMAIN.md
@@ -0,0 +1,266 @@
+"""Normalized User Domain Schema Design
+
+This module implements a fully normalized user domain following:
+- PostgreSQL 17 features
+- Third Normal Form (3NF)
+- Domain-Driven Design (DDD) principles
+- Modular Monolith Architecture
+- CQRS compatibility
+- Multi-tenancy readiness
+- Audit-friendly design
+
+## Domain Analysis: Why Monolithic Users Table is Bad
+
+1. **Single Responsibility Principle Violation**: A monolithic users table mixes identity,
+ profile, security, preferences, and contact information in one place. This makes it
+ difficult to reason about and maintain.
+
+2. **Scalability Bottlenecks**: As the table grows wide with many columns, every query
+ loads unnecessary data. Index efficiency decreases, and vacuum operations become slower.
+
+3. **Security Concerns**: Sensitive data like password hashes and security settings should
+ be isolated from frequently accessed profile data to minimize exposure surface.
+
+4. **Multi-Tenancy Complexity**: Adding tenant isolation to a wide table requires careful
+ consideration of which fields need tenant scoping.
+
+5. **CQRS Incompatibility**: Command and Query Responsibility Segregation becomes difficult
+ when read models need different projections than write models from the same table.
+
+6. **Microservice Extraction**: When extracting services, a monolithic table creates tight
+ coupling. Separated tables allow clean bounded context boundaries.
+
+7. **Audit Trail Gaps**: Tracking changes across many unrelated fields in one table is
+ complex and error-prone.
+
+8. **Performance Contention**: Hot spots form when unrelated operations compete for locks
+ on the same row.
+
+## Recommended Normalized Structure
+
+### Identity Bounded Context
+- **users**: Core identity and authentication credentials
+- **user_security**: Security state, lockouts, MFA configuration
+- **user_verifications**: Verification status per communication channel
+- **user_sessions**: Active sessions and refresh tokens
+
+### Profile Bounded Context
+- **user_profiles**: Personal information (names, bio, avatar)
+- **user_contacts**: Multiple contact methods with types
+- **user_addresses**: Multiple addresses with labels
+- **user_settings**: User preferences in flexible JSONB format
+
+### Access Control Bounded Context
+- **roles**: Role definitions
+- **permissions**: Permission definitions
+- **role_permissions**: Role-to-permission assignments
+- **user_has_roles**: User-to-role assignments
+
+### Audit Bounded Context
+- **audit_logs**: Immutable event log for compliance
+- **error_traces**: Error tracking for debugging
+
+## ERD Diagram (Mermaid)
+
+```mermaid
+erDiagram
+ users ||--o| user_profiles : "has"
+ users ||--o| user_security : "has"
+ users ||--o| user_contacts : "has multiple"
+ users ||--o| user_addresses : "has multiple"
+ users ||--o| user_settings : "has"
+ users ||--o| user_verifications : "has multiple"
+ users ||--o| user_sessions : "has multiple"
+ users ||--o| user_has_roles : "assigned"
+
+ roles ||--o{ role_permissions : "contains"
+ permissions ||--o{ role_permissions : "contained in"
+ roles ||--o{ user_has_roles : "assigned to"
+ users ||--o{ user_has_roles : "has roles"
+
+ authorization_resources ||--o{ permissions : "defines"
+
+ users {
+ uuid id PK
+ varchar email UK
+ varchar username UK
+ varchar password_hash
+ varchar auth_provider
+ varchar status
+ timestamptz created_at
+ timestamptz updated_at
+ }
+
+ user_profiles {
+ uuid id PK
+ uuid user_id FK UK
+ varchar first_name
+ varchar last_name
+ varchar display_name
+ varchar avatar_url
+ text bio
+ date birth_date
+ }
+
+ user_security {
+ uuid id PK
+ uuid user_id FK UK
+ int failed_login_attempts
+ timestamptz locked_until
+ timestamptz password_changed_at
+ boolean two_factor_enabled
+ varchar two_factor_secret
+ }
+
+ user_contacts {
+ uuid id PK
+ uuid user_id FK
+ varchar type
+ varchar value
+ boolean is_primary
+ boolean is_verified
+ }
+
+ user_addresses {
+ uuid id PK
+ uuid user_id FK
+ varchar label
+ varchar line1
+ varchar line2
+ varchar city
+ varchar state
+ varchar postal_code
+ varchar country
+ boolean is_default
+ }
+
+ user_settings {
+ uuid id PK
+ uuid user_id FK UK
+ jsonb preferences
+ }
+
+ user_verifications {
+ uuid id PK
+ uuid user_id FK
+ varchar channel
+ boolean is_verified
+ timestamptz verified_at
+ varchar verification_token
+ }
+
+ user_sessions {
+ uuid id PK
+ uuid user_id FK
+ varchar refresh_token_hash
+ timestamptz expires_at
+ varchar device_info
+ varchar ip_address
+ boolean is_revoked
+ }
+
+ roles {
+ uuid id PK
+ varchar name UK
+ varchar description
+ }
+
+ permissions {
+ uuid id PK
+ uuid resource_id FK
+ varchar key UK
+ varchar resource
+ varchar action
+ varchar description
+ }
+
+ authorization_resources {
+ uuid id PK
+ varchar key UK
+ varchar name
+ varchar description
+ }
+
+ role_permissions {
+ uuid id PK
+ uuid role_id FK
+ uuid permission_id FK
+ }
+
+ user_has_roles {
+ uuid id PK
+ uuid user_id FK
+ uuid role_id FK
+ }
+
+ audit_logs {
+ uuid id PK
+ varchar action
+ uuid actor_id
+ varchar resource_type
+ uuid resource_id
+ varchar request_id
+ jsonb meta
+ timestamptz created_at
+ }
+```
+
+## DDD Mapping
+
+### Aggregates
+- **UserAggregate**: Root entity `users` with entities `user_security`, `user_profiles`
+- **SessionAggregate**: Root entity `user_sessions`
+- **RoleAggregate**: Root entity `roles` with `role_permissions`
+
+### Entities
+- `users`: Identity aggregate root
+- `user_security`: Security configuration entity
+- `user_profiles`: Profile entity
+- `user_contacts`: Contact method entity
+- `user_addresses`: Address entity
+- `user_sessions`: Session entity
+- `roles`: Role aggregate root
+- `permissions`: Permission entity
+
+### Value Objects
+- `Email`: Email address with validation
+- `PhoneNumber`: Phone number with formatting
+- `Address`: Structured address components
+- `Preferences`: JSONB settings object
+
+### Domain Services
+- `AuthenticationService`: Login/logout/password management
+- `AuthorizationService`: RBAC evaluation
+- `VerificationService`: Email/phone verification
+- `SessionService`: Session lifecycle management
+- `AuditService`: Audit log creation
+
+## Future Scalability Considerations
+
+### Millions of Users
+- Partition `audit_logs` and `user_sessions` by date
+- Use connection pooling efficiently
+- Implement read replicas for query separation
+- Cache frequently accessed profiles
+
+### Multi-Tenancy
+- Add `tenant_id` column to all tables
+- Implement Row Level Security (RLS) policies
+- Use schema-per-tenant for high isolation needs
+
+### OAuth/SSO Support
+- `auth_provider` field supports external identity providers
+- `external_id` can be added for provider-specific IDs
+- `user_verifications` tracks OAuth account linking
+
+### Microservice Extraction
+- Each bounded context can become a separate service
+- Clear foreign key boundaries enable database splitting
+- Event sourcing ready via `audit_logs`
+
+### Event-Driven Architecture
+- `audit_logs` serves as event store
+- Can integrate with message brokers (Kafka, RabbitMQ)
+- Supports CQRS read model rebuilding
+
+"""
diff --git a/docs/superpowers/plans/2026-06-22-normalized-database-seed.md b/docs/superpowers/plans/2026-06-22-normalized-database-seed.md
new file mode 100644
index 0000000..9d62c46
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-22-normalized-database-seed.md
@@ -0,0 +1,387 @@
+# Normalized Database Seed Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make configured user seeds compatible with the normalized user schema and document exactly which authorization and user records the seed creates.
+
+**Architecture:** Keep orchestration in `src/core/seed/user.py` behind its repository and authorization protocols. Create identity data through the current `User` factory, then persist the legacy full-name setting as `UserProfile.display_name`; the existing SQLAlchemy repository remains responsible for default profile, settings, and security rows and the runner retains transaction ownership.
+
+**Tech Stack:** Python 3.14, dataclasses, async repository protocols, SQLAlchemy async repository adapter, pytest, Ruff, Markdown.
+
+---
+
+## File Structure
+
+- Create `tests/test_user_seed.py`: unit tests for normalized user/profile seeding, environment gating, idempotency, role assignment, and counters.
+- Modify `src/core/seed/user.py`: correct the `User.create()` call and persist seed names through `UserProfile`.
+- Modify `README.md`: document authorization records, normalized user records, configuration mapping, transaction behavior, and existing-user behavior.
+
+### Task 1: Add normalized user seed contract tests
+
+**Files:**
+- Create: `tests/test_user_seed.py`
+
+- [ ] **Step 1: Write test fakes and a failing admin seed test**
+
+Create `tests/test_user_seed.py` with:
+
+```python
+import asyncio
+from uuid import uuid4
+
+from src.core.seed.user import SeedUserConfig, seed_user
+from src.modules.authorization.domain.permissions import (
+ ADMIN_ROLE,
+ DEFAULT_USER_ROLE,
+ MANAGER_ROLE,
+ VIEWER_ROLE,
+)
+from src.modules.user.domain.entities.user import User, UserProfile
+
+
+class FakeUserRepository:
+ def __init__(self, existing_users: tuple[User, ...] = ()) -> None:
+ self.users = {user.email: user for user in existing_users}
+ self.saved_users: list[User] = []
+ self.saved_profiles: list[UserProfile] = []
+
+ async def get_by_email(self, email: str) -> User | None:
+ return self.users.get(email)
+
+ async def save(self, user: User) -> User:
+ self.users[user.email] = user
+ self.saved_users.append(user)
+ return user
+
+ async def save_profile(self, profile: UserProfile) -> UserProfile:
+ self.saved_profiles.append(profile)
+ return profile
+
+
+class FakeAuthorizationService:
+ def __init__(self) -> None:
+ self.assignments: list[tuple[str, str]] = []
+
+ async def assign_role(self, subject: str, role: str) -> None:
+ self.assignments.append((subject, role))
+
+
+def test_seed_user_creates_admin_with_normalized_profile(monkeypatch):
+ monkeypatch.setattr(
+ "src.core.seed.user.PasswordSerrvice.hash",
+ lambda password: f"hashed:{password}",
+ )
+ repository = FakeUserRepository()
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="production",
+ admin_email="admin@example.com",
+ admin_password="secret-password",
+ admin_username="admin",
+ admin_fullname="System Administrator",
+ ),
+ )
+ )
+
+ assert result.users_created == 1
+ assert result.roles_assigned == 1
+ assert len(repository.saved_users) == 1
+ saved_user = repository.saved_users[0]
+ assert saved_user.email == "admin@example.com"
+ assert saved_user.username == "admin"
+ assert saved_user.password_hash == "hashed:secret-password"
+ assert repository.saved_profiles == [
+ UserProfile(user_id=saved_user.id, display_name="System Administrator")
+ ]
+ assert authorization.assignments == [(str(saved_user.id), ADMIN_ROLE)]
+```
+
+- [ ] **Step 2: Run the admin test to verify the current factory mismatch fails**
+
+Run: `.venv/bin/pytest tests/test_user_seed.py::test_seed_user_creates_admin_with_normalized_profile -v`
+
+Expected: FAIL with `TypeError` because `User.create()` currently receives unsupported `password` and `fullname` keyword arguments.
+
+- [ ] **Step 3: Add failing development, existing-user, and missing-credentials tests**
+
+Append:
+
+```python
+def test_seed_user_creates_development_users_and_profiles(monkeypatch):
+ monkeypatch.setattr(
+ "src.core.seed.user.PasswordSerrvice.hash",
+ lambda password: f"hashed:{password}",
+ )
+ repository = FakeUserRepository()
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="development",
+ admin_email="",
+ admin_password="",
+ development_users_password="demo-password",
+ ),
+ )
+ )
+
+ assert result.users_created == 3
+ assert result.roles_assigned == 3
+ assert [user.email for user in repository.saved_users] == [
+ "user@example.com",
+ "manager@example.com",
+ "viewer@example.com",
+ ]
+ assert [profile.display_name for profile in repository.saved_profiles] == [
+ "Default User",
+ "Todo Manager",
+ "Todo Viewer",
+ ]
+ assert [role for _, role in authorization.assignments] == [
+ DEFAULT_USER_ROLE,
+ MANAGER_ROLE,
+ VIEWER_ROLE,
+ ]
+
+
+def test_seed_user_does_not_modify_an_existing_user(monkeypatch):
+ monkeypatch.setattr(
+ "src.core.seed.user.PasswordSerrvice.hash",
+ lambda password: f"hashed:{password}",
+ )
+ existing_user = User(
+ id=uuid4(),
+ email="admin@example.com",
+ password_hash="existing-hash",
+ username="existing-admin",
+ )
+ repository = FakeUserRepository((existing_user,))
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="production",
+ admin_email="admin@example.com",
+ admin_password="new-password",
+ admin_username="admin",
+ admin_fullname="System Administrator",
+ ),
+ )
+ )
+
+ assert result.users_created == 0
+ assert result.roles_assigned == 0
+ assert repository.saved_users == []
+ assert repository.saved_profiles == []
+ assert authorization.assignments == []
+ assert repository.users[existing_user.email] == existing_user
+
+
+def test_seed_user_skips_users_without_credentials():
+ repository = FakeUserRepository()
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="production",
+ admin_email="",
+ admin_password="",
+ ),
+ )
+ )
+
+ assert result.users_created == 0
+ assert result.roles_assigned == 0
+ assert repository.saved_users == []
+ assert repository.saved_profiles == []
+ assert authorization.assignments == []
+```
+
+- [ ] **Step 4: Run the test file**
+
+Run: `.venv/bin/pytest tests/test_user_seed.py -v`
+
+Expected: missing-credentials and existing-user tests PASS; admin and development creation tests FAIL at the outdated `User.create()` call.
+
+- [ ] **Step 5: Commit the failing tests**
+
+```bash
+git add tests/test_user_seed.py
+git commit -m "test: cover normalized user seeding"
+```
+
+### Task 2: Persist seeded names through normalized profiles
+
+**Files:**
+- Modify: `src/core/seed/user.py:1-161`
+- Test: `tests/test_user_seed.py`
+
+- [ ] **Step 1: Import `UserProfile` and extend the seed repository contract**
+
+```python
+from src.modules.user.domain.entities.user import User, UserProfile
+
+
+class SeedUserRepository(Protocol):
+ async def get_by_email(self, email: str) -> User | None:
+ raise NotImplementedError
+
+ async def save(self, user: User) -> User:
+ raise NotImplementedError
+
+ async def save_profile(self, profile: UserProfile) -> UserProfile:
+ raise NotImplementedError
+```
+
+- [ ] **Step 2: Correct user creation and save the normalized profile before role assignment**
+
+Replace the creation block in `_seed_one_user()` with:
+
+```python
+ user = User.create(
+ email=email,
+ password_hash=PasswordSerrvice.hash(password),
+ username=username,
+ )
+ saved_user = await user_repository.save(user)
+ await user_repository.save_profile(
+ UserProfile(
+ user_id=saved_user.id,
+ display_name=fullname,
+ )
+ )
+ await authorization_service.assign_role(str(saved_user.id), role)
+```
+
+This preserves ordering inside the runner transaction: identity and defaults, profile value, then role assignment.
+
+- [ ] **Step 3: Run seed tests**
+
+Run: `.venv/bin/pytest tests/test_user_seed.py -v`
+
+Expected: 4 tests PASS.
+
+- [ ] **Step 4: Run all tests**
+
+Run: `.venv/bin/pytest -q`
+
+Expected: all tests PASS.
+
+- [ ] **Step 5: Run focused lint**
+
+Run: `.venv/bin/ruff check src/core/seed/user.py tests/test_user_seed.py`
+
+Expected: `All checks passed!`
+
+- [ ] **Step 6: Commit the implementation**
+
+```bash
+git add src/core/seed/user.py
+git commit -m "fix: align user seed with normalized schema"
+```
+
+### Task 3: Document normalized seed behavior
+
+**Files:**
+- Modify: `README.md:431-462`
+
+- [ ] **Step 1: Replace the database-seeding section with schema-accurate text**
+
+The revised section must state:
+
+````markdown
+Seed baseline records after applying migrations:
+
+```bash
+make seed
+```
+
+The seeder runs all changes in one transaction and is idempotent. It creates the default authorization resources, roles (`admin`, `user`, `manager`, and `viewer`), permissions, role-permission links, and matching Casbin policies without duplicating existing records.
+
+For each new seeded user, the repository creates records that follow the normalized user schema:
+
+- `users` stores email, username, password hash, authentication provider, and status.
+- `user_profiles` stores `SEED_ADMIN_FULLNAME` (or the demo name) as `display_name`.
+- `user_settings` stores default preferences.
+- `user_security` stores default security state.
+- `user_has_roles` associates the user with its seeded role.
+
+`SEED_ADMIN_FULLNAME` is retained for configuration compatibility; there is no `users.fullname` column. Existing users are not modified.
+````
+
+Keep the existing environment examples, add display names to the demo-account list, and explicitly describe transaction rollback and production/development gating.
+
+- [ ] **Step 2: Search for obsolete seed claims**
+
+Run: `rg -n "SEED_ADMIN_FULLNAME|fullname|default .*roles|user_profiles|user_settings|user_security" README.md src/core/seed`
+
+Expected: README maps `SEED_ADMIN_FULLNAME` to `user_profiles.display_name`; source uses `fullname` only as seed input; README lists all four roles.
+
+- [ ] **Step 3: Check whitespace**
+
+Run: `git diff --check`
+
+Expected: no output and exit status 0.
+
+- [ ] **Step 4: Commit README changes**
+
+```bash
+git add README.md
+git commit -m "docs: explain normalized database seeding"
+```
+
+### Task 4: Verify the complete change
+
+**Files:**
+- Verify: `src/core/seed/user.py`
+- Verify: `tests/test_user_seed.py`
+- Verify: `README.md`
+
+- [ ] **Step 1: Run the full test suite**
+
+Run: `.venv/bin/pytest -q`
+
+Expected: all tests PASS.
+
+- [ ] **Step 2: Run project lint**
+
+Run: `.venv/bin/ruff check src tests scripts`
+
+Expected: `All checks passed!`
+
+- [ ] **Step 3: Run import-boundary checks**
+
+Run: `.venv/bin/lint-imports`
+
+Expected: all configured contracts are kept.
+
+- [ ] **Step 4: Verify application imports**
+
+Run: `PYTHONDONTWRITEBYTECODE=1 .venv/bin/python -c "import src.main; print('import ok')"`
+
+Expected: `import ok`.
+
+- [ ] **Step 5: Inspect the final scoped diff and worktree**
+
+Run:
+
+```bash
+git diff HEAD~3 -- src/core/seed/user.py tests/test_user_seed.py README.md
+git status --short
+```
+
+Expected: only the planned seed, tests, and README changes appear; the pre-existing untracked `.vscode/PythonImportHelper-v2-Completion.json` remains untouched.
diff --git a/docs/superpowers/plans/2026-06-22-restore-database-foreign-keys.md b/docs/superpowers/plans/2026-06-22-restore-database-foreign-keys.md
new file mode 100644
index 0000000..03a9aac
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-22-restore-database-foreign-keys.md
@@ -0,0 +1,512 @@
+# Restore Database Foreign Keys Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Restore all 13 intended foreign keys, align normalized user identifiers with UUID, repair already-migrated databases, and make `make seed` complete without SQLAlchemy mapper errors.
+
+**Architecture:** Treat SQLAlchemy model metadata as the relationship source of truth and load the full model graph in Alembic. Add a forward-only corrective revision after `e73c215d7221` that converts seven normalized user columns to UUID and restores all missing constraints; leave the already-applied normalization revision unchanged.
+
+**Tech Stack:** Python 3.14, SQLAlchemy 2, Alembic, PostgreSQL UUID, pytest, Ruff.
+
+---
+
+## File Structure
+
+- Create `tests/test_database_relationships.py`: mapper, foreign-key target, and UUID metadata regression tests.
+- Modify `src/modules/authorization/infrastructure/models/permission_model.py`: restore the permission-to-resource foreign key.
+- Modify `src/modules/authorization/infrastructure/models/role_permission_model.py`: restore both role-permission junction foreign keys.
+- Modify `src/modules/authorization/infrastructure/models/user_has_role_model.py`: restore both user-role junction foreign keys.
+- Modify `src/modules/todo/infrastructure/models/todo_model.py`: restore the todo owner foreign key.
+- Modify seven files under `src/modules/user/infrastructure/models/`: use UUID user identifiers and restore user foreign keys.
+- Modify `alembic/env.py`: register the complete normalized user model package with Alembic metadata.
+- Create `tests/test_restore_database_foreign_keys_migration.py`: verify corrective upgrade and downgrade operations without touching a database.
+- Create `alembic/versions/f4a8c2d1e6b9_restore_database_foreign_keys.py`: repair existing schemas after `e73c215d7221`.
+
+### Task 1: Add failing ORM relationship regression tests
+
+**Files:**
+- Create: `tests/test_database_relationships.py`
+
+- [ ] **Step 1: Write the mapper and metadata tests**
+
+Create `tests/test_database_relationships.py`:
+
+```python
+import pytest
+from sqlalchemy import Uuid
+from sqlalchemy.orm import configure_mappers
+
+import src.modules.authorization.infrastructure.models.permission_model # noqa: F401
+import src.modules.authorization.infrastructure.models.resource_model # noqa: F401
+import src.modules.authorization.infrastructure.models.role_model # noqa: F401
+import src.modules.authorization.infrastructure.models.role_permission_model # noqa: F401
+import src.modules.authorization.infrastructure.models.user_has_role_model # noqa: F401
+import src.modules.todo.infrastructure.models.todo_model # noqa: F401
+import src.modules.user.infrastructure.models # noqa: F401
+from src.shared.database.model import Base
+
+
+FOREIGN_KEYS = (
+ ("permissions", "resource_id", "authorization_resources.id"),
+ ("role_permissions", "role_id", "roles.id"),
+ ("role_permissions", "permission_id", "permissions.id"),
+ ("todos", "user_id", "users.id"),
+ ("user_has_roles", "user_id", "users.id"),
+ ("user_has_roles", "role_id", "roles.id"),
+ ("user_profiles", "user_id", "users.id"),
+ ("user_security", "user_id", "users.id"),
+ ("user_settings", "user_id", "users.id"),
+ ("user_contacts", "user_id", "users.id"),
+ ("user_addresses", "user_id", "users.id"),
+ ("user_verifications", "user_id", "users.id"),
+ ("user_sessions", "user_id", "users.id"),
+)
+
+NORMALIZED_USER_TABLES = (
+ "user_profiles",
+ "user_security",
+ "user_settings",
+ "user_contacts",
+ "user_addresses",
+ "user_verifications",
+ "user_sessions",
+)
+
+
+def test_all_mappers_configure_with_declared_relationship_joins():
+ configure_mappers()
+
+
+@pytest.mark.parametrize(("table", "column", "target"), FOREIGN_KEYS)
+def test_relationship_column_declares_expected_foreign_key(table, column, target):
+ foreign_keys = Base.metadata.tables[table].c[column].foreign_keys
+
+ assert {foreign_key.target_fullname for foreign_key in foreign_keys} == {target}
+
+
+@pytest.mark.parametrize("table", NORMALIZED_USER_TABLES)
+def test_normalized_user_identifier_uses_uuid(table):
+ assert isinstance(Base.metadata.tables[table].c.user_id.type, Uuid)
+```
+
+- [ ] **Step 2: Run the test to verify RED**
+
+Run: `.venv/bin/pytest tests/test_database_relationships.py -v`
+
+Expected: FAIL with the current `NoForeignKeysError`, missing foreign-key target assertions, and string-type assertions. The test must fail for metadata defects, not import or syntax errors.
+
+- [ ] **Step 3: Commit the failing regression test**
+
+```bash
+git add tests/test_database_relationships.py
+git commit -m "test: reproduce missing database foreign keys"
+```
+
+### Task 2: Restore ORM foreign-key metadata and model discovery
+
+**Files:**
+- Modify: `src/modules/authorization/infrastructure/models/permission_model.py`
+- Modify: `src/modules/authorization/infrastructure/models/role_permission_model.py`
+- Modify: `src/modules/authorization/infrastructure/models/user_has_role_model.py`
+- Modify: `src/modules/todo/infrastructure/models/todo_model.py`
+- Modify: `src/modules/user/infrastructure/models/user_profile_model.py`
+- Modify: `src/modules/user/infrastructure/models/user_security_model.py`
+- Modify: `src/modules/user/infrastructure/models/user_settings_model.py`
+- Modify: `src/modules/user/infrastructure/models/user_contact_model.py`
+- Modify: `src/modules/user/infrastructure/models/user_address_model.py`
+- Modify: `src/modules/user/infrastructure/models/user_verification_model.py`
+- Modify: `src/modules/user/infrastructure/models/refresh_token_model.py`
+- Modify: `alembic/env.py`
+- Test: `tests/test_database_relationships.py`
+
+- [ ] **Step 1: Restore authorization and todo foreign keys**
+
+Add `ForeignKey` to each SQLAlchemy import and use these exact column definitions:
+
+```python
+# src/modules/authorization/infrastructure/models/permission_model.py
+resource_id: Mapped[UUID] = mapped_column(
+ ForeignKey("authorization_resources.id"),
+ nullable=False,
+)
+
+# src/modules/authorization/infrastructure/models/role_permission_model.py
+role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), nullable=False)
+permission_id: Mapped[UUID] = mapped_column(
+ ForeignKey("permissions.id"),
+ nullable=False,
+)
+
+# src/modules/authorization/infrastructure/models/user_has_role_model.py
+user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), nullable=False)
+
+# src/modules/todo/infrastructure/models/todo_model.py
+user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"))
+```
+
+- [ ] **Step 2: Convert every normalized user link to UUID with a foreign key**
+
+In each listed user model, import `UUID` from `uuid`, import `ForeignKey` from SQLAlchemy, remove `String(36)` from `user_id`, and preserve the existing uniqueness/nullability options:
+
+```python
+# user_profile_model.py
+user_id: Mapped[UUID] = mapped_column(
+ ForeignKey("users.id"),
+ unique=True,
+ nullable=False,
+)
+
+# user_security_model.py
+user_id: Mapped[UUID] = mapped_column(
+ ForeignKey("users.id"),
+ unique=True,
+ nullable=False,
+)
+
+# user_settings_model.py
+user_id: Mapped[UUID] = mapped_column(
+ ForeignKey("users.id"),
+ unique=True,
+ nullable=False,
+)
+
+# user_contact_model.py
+user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+
+# user_address_model.py
+user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+
+# user_verification_model.py
+user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+
+# refresh_token_model.py (UserSessionModel)
+user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
+```
+
+Keep `String` imports in files that use it for other columns. Remove it only from `user_settings_model.py`, where it becomes unused.
+
+- [ ] **Step 3: Register the complete user model package in Alembic**
+
+Replace the two direct user-model imports in `alembic/env.py` with:
+
+```python
+from src.modules.user.infrastructure import models as user_models # noqa: F401
+```
+
+This imports every class exported by `src/modules/user/infrastructure/models/__init__.py` before `target_metadata = Base.metadata` is evaluated.
+
+- [ ] **Step 4: Run the focused tests to verify GREEN**
+
+Run: `.venv/bin/pytest tests/test_database_relationships.py -v`
+
+Expected: `21 passed` (one mapper test, 13 foreign-key cases, seven UUID cases).
+
+- [ ] **Step 5: Run focused lint**
+
+Run:
+
+```bash
+.venv/bin/ruff check \
+ alembic/env.py \
+ src/modules/authorization/infrastructure/models \
+ src/modules/todo/infrastructure/models/todo_model.py \
+ src/modules/user/infrastructure/models \
+ tests/test_database_relationships.py
+```
+
+Expected: `All checks passed!`
+
+- [ ] **Step 6: Commit ORM metadata repair**
+
+```bash
+git add alembic/env.py src/modules/authorization/infrastructure/models src/modules/todo/infrastructure/models/todo_model.py src/modules/user/infrastructure/models tests/test_database_relationships.py
+git commit -m "fix: restore ORM foreign key metadata"
+```
+
+### Task 3: Add failing corrective-migration contract tests
+
+**Files:**
+- Create: `tests/test_restore_database_foreign_keys_migration.py`
+
+- [ ] **Step 1: Write upgrade and downgrade operation tests**
+
+Create `tests/test_restore_database_foreign_keys_migration.py`:
+
+```python
+import importlib.util
+from pathlib import Path
+
+from sqlalchemy import String, Uuid
+
+
+MIGRATION_PATH = Path(
+ "alembic/versions/f4a8c2d1e6b9_restore_database_foreign_keys.py"
+)
+USER_TABLES = (
+ "user_profiles",
+ "user_security",
+ "user_settings",
+ "user_contacts",
+ "user_addresses",
+ "user_verifications",
+ "user_sessions",
+)
+FOREIGN_KEYS = {
+ ("fk_permissions_resource_id_authorization_resources", "permissions", "authorization_resources", "resource_id"),
+ ("role_permissions_role_id_fkey", "role_permissions", "roles", "role_id"),
+ ("role_permissions_permission_id_fkey", "role_permissions", "permissions", "permission_id"),
+ ("todos_user_id_fkey", "todos", "users", "user_id"),
+ ("user_has_roles_user_id_fkey", "user_has_roles", "users", "user_id"),
+ ("user_has_roles_role_id_fkey", "user_has_roles", "roles", "role_id"),
+ ("fk_user_profiles_user_id_users", "user_profiles", "users", "user_id"),
+ ("fk_user_security_user_id_users", "user_security", "users", "user_id"),
+ ("fk_user_settings_user_id_users", "user_settings", "users", "user_id"),
+ ("fk_user_contacts_user_id_users", "user_contacts", "users", "user_id"),
+ ("fk_user_addresses_user_id_users", "user_addresses", "users", "user_id"),
+ ("fk_user_verifications_user_id_users", "user_verifications", "users", "user_id"),
+ ("fk_user_sessions_user_id_users", "user_sessions", "users", "user_id"),
+}
+
+
+def load_migration():
+ assert MIGRATION_PATH.exists(), "corrective migration does not exist"
+ spec = importlib.util.spec_from_file_location("restore_foreign_keys", MIGRATION_PATH)
+ assert spec is not None and spec.loader is not None
+ migration = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(migration)
+ return migration
+
+
+def test_upgrade_converts_user_ids_and_creates_all_foreign_keys(monkeypatch):
+ migration = load_migration()
+ altered = []
+ created = []
+ monkeypatch.setattr(
+ migration.op,
+ "alter_column",
+ lambda table, column, **kwargs: altered.append((table, column, kwargs)),
+ )
+ monkeypatch.setattr(
+ migration.op,
+ "create_foreign_key",
+ lambda name, source, target, local, remote: created.append(
+ (name, source, target, local[0], remote[0])
+ ),
+ )
+
+ migration.upgrade()
+
+ assert [item[:2] for item in altered] == [
+ (table, "user_id") for table in USER_TABLES
+ ]
+ assert all(isinstance(kwargs["type_"], Uuid) for _, _, kwargs in altered)
+ assert all(kwargs["postgresql_using"] == "user_id::uuid" for _, _, kwargs in altered)
+ assert set(created) == {(*foreign_key, "id") for foreign_key in FOREIGN_KEYS}
+
+
+def test_downgrade_drops_constraints_and_restores_string_user_ids(monkeypatch):
+ migration = load_migration()
+ dropped = []
+ altered = []
+ monkeypatch.setattr(
+ migration.op,
+ "drop_constraint",
+ lambda name, table, **kwargs: dropped.append((name, table, kwargs)),
+ )
+ monkeypatch.setattr(
+ migration.op,
+ "alter_column",
+ lambda table, column, **kwargs: altered.append((table, column, kwargs)),
+ )
+
+ migration.downgrade()
+
+ assert {(name, table) for name, table, _ in dropped} == {
+ (name, table) for name, table, _, _ in FOREIGN_KEYS
+ }
+ assert all(kwargs == {"type_": "foreignkey"} for _, _, kwargs in dropped)
+ assert [item[:2] for item in altered] == [
+ (table, "user_id") for table in reversed(USER_TABLES)
+ ]
+ assert all(isinstance(kwargs["type_"], String) for _, _, kwargs in altered)
+ assert all(kwargs["postgresql_using"] == "user_id::text" for _, _, kwargs in altered)
+```
+
+- [ ] **Step 2: Run the tests to verify RED**
+
+Run: `.venv/bin/pytest tests/test_restore_database_foreign_keys_migration.py -v`
+
+Expected: two assertion failures with `corrective migration does not exist`.
+
+- [ ] **Step 3: Commit the failing migration tests**
+
+```bash
+git add tests/test_restore_database_foreign_keys_migration.py
+git commit -m "test: specify corrective foreign key migration"
+```
+
+### Task 4: Implement the corrective Alembic migration
+
+**Files:**
+- Create: `alembic/versions/f4a8c2d1e6b9_restore_database_foreign_keys.py`
+- Test: `tests/test_restore_database_foreign_keys_migration.py`
+
+- [ ] **Step 1: Create the corrective revision**
+
+Create `alembic/versions/f4a8c2d1e6b9_restore_database_foreign_keys.py`:
+
+```python
+"""restore database foreign keys
+
+Revision ID: f4a8c2d1e6b9
+Revises: e73c215d7221
+Create Date: 2026-06-22
+"""
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+
+revision: str = "f4a8c2d1e6b9"
+down_revision: str | Sequence[str] | None = "e73c215d7221"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+USER_TABLES = (
+ "user_profiles",
+ "user_security",
+ "user_settings",
+ "user_contacts",
+ "user_addresses",
+ "user_verifications",
+ "user_sessions",
+)
+
+FOREIGN_KEYS = (
+ ("fk_permissions_resource_id_authorization_resources", "permissions", "authorization_resources", "resource_id"),
+ ("role_permissions_role_id_fkey", "role_permissions", "roles", "role_id"),
+ ("role_permissions_permission_id_fkey", "role_permissions", "permissions", "permission_id"),
+ ("todos_user_id_fkey", "todos", "users", "user_id"),
+ ("user_has_roles_user_id_fkey", "user_has_roles", "users", "user_id"),
+ ("user_has_roles_role_id_fkey", "user_has_roles", "roles", "role_id"),
+ ("fk_user_profiles_user_id_users", "user_profiles", "users", "user_id"),
+ ("fk_user_security_user_id_users", "user_security", "users", "user_id"),
+ ("fk_user_settings_user_id_users", "user_settings", "users", "user_id"),
+ ("fk_user_contacts_user_id_users", "user_contacts", "users", "user_id"),
+ ("fk_user_addresses_user_id_users", "user_addresses", "users", "user_id"),
+ ("fk_user_verifications_user_id_users", "user_verifications", "users", "user_id"),
+ ("fk_user_sessions_user_id_users", "user_sessions", "users", "user_id"),
+)
+
+
+def upgrade() -> None:
+ for table in USER_TABLES:
+ op.alter_column(
+ table,
+ "user_id",
+ existing_type=sa.String(length=36),
+ type_=sa.Uuid(),
+ existing_nullable=False,
+ postgresql_using="user_id::uuid",
+ )
+
+ for name, source, target, column in FOREIGN_KEYS:
+ op.create_foreign_key(name, source, target, [column], ["id"])
+
+
+def downgrade() -> None:
+ for name, source, _, _ in reversed(FOREIGN_KEYS):
+ op.drop_constraint(name, source, type_="foreignkey")
+
+ for table in reversed(USER_TABLES):
+ op.alter_column(
+ table,
+ "user_id",
+ existing_type=sa.Uuid(),
+ type_=sa.String(length=36),
+ existing_nullable=False,
+ postgresql_using="user_id::text",
+ )
+```
+
+- [ ] **Step 2: Run migration contract tests to verify GREEN**
+
+Run: `.venv/bin/pytest tests/test_restore_database_foreign_keys_migration.py -v`
+
+Expected: `2 passed`.
+
+- [ ] **Step 3: Verify the Alembic revision graph**
+
+Run: `.venv/bin/alembic heads`
+
+Expected: exactly one head, `f4a8c2d1e6b9 (head)`.
+
+- [ ] **Step 4: Run focused lint**
+
+Run: `.venv/bin/ruff check alembic/versions/f4a8c2d1e6b9_restore_database_foreign_keys.py tests/test_restore_database_foreign_keys_migration.py`
+
+Expected: `All checks passed!`
+
+- [ ] **Step 5: Commit the corrective migration**
+
+```bash
+git add alembic/versions/f4a8c2d1e6b9_restore_database_foreign_keys.py tests/test_restore_database_foreign_keys_migration.py
+git commit -m "fix: restore database foreign key constraints"
+```
+
+### Task 5: Verify migrations, seeding, and the full project
+
+**Files:**
+- No code changes expected.
+
+- [ ] **Step 1: Run both focused regression suites**
+
+Run:
+
+```bash
+.venv/bin/pytest \
+ tests/test_database_relationships.py \
+ tests/test_restore_database_foreign_keys_migration.py \
+ -v
+```
+
+Expected: `23 passed`.
+
+- [ ] **Step 2: Run the full automated checks**
+
+Run: `make check`
+
+Expected: pytest passes, Ruff reports no errors, import-linter contracts pass, and `src.main` prints `import ok`.
+
+- [ ] **Step 3: Inspect the configured database revision before mutation**
+
+Run: `.venv/bin/alembic current`
+
+Expected: a valid current revision. Record it before proceeding. If the command cannot connect, report the database prerequisite instead of claiming database verification.
+
+- [ ] **Step 4: Apply the corrective migration**
+
+Run: `make migrate`
+
+Expected: Alembic upgrades to `f4a8c2d1e6b9` without cast or referential-integrity errors. If PostgreSQL rejects malformed UUIDs or orphaned rows, stop and report the exact rows/constraint category; do not delete or rewrite data automatically.
+
+- [ ] **Step 5: Confirm the new revision**
+
+Run: `.venv/bin/alembic current`
+
+Expected: `f4a8c2d1e6b9 (head)`.
+
+- [ ] **Step 6: Run the original failing workflow**
+
+Run: `make seed`
+
+Expected: seed summary output and exit status 0, with no `NoForeignKeysError`.
+
+- [ ] **Step 7: Confirm the working tree contains no unintended files**
+
+Run: `git status --short`
+
+Expected: only pre-existing unrelated user files, if any. Do not add `.vscode/PythonImportHelper-v2-Completion.json`.
diff --git a/docs/superpowers/specs/2026-06-22-normalized-database-seed-design.md b/docs/superpowers/specs/2026-06-22-normalized-database-seed-design.md
new file mode 100644
index 0000000..8c97253
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-22-normalized-database-seed-design.md
@@ -0,0 +1,64 @@
+# Normalized Database Seed Design
+
+## Goal
+
+Update user seeding to work with the normalized user database schema while preserving the existing seed configuration and idempotent behavior. Update the README so operators can understand which records are created and how `SEED_ADMIN_FULLNAME` maps to the current schema.
+
+## Current Problem
+
+The user seeder passes `fullname` to `User.create()`. The normalized `users` table and current domain factory no longer accept that field. Personal names now belong in `user_profiles`, specifically `display_name` for the existing seed value. As a result, configured user seeding fails before a user can be persisted.
+
+## Design
+
+### User creation
+
+The seeder will create each user with the current identity fields only: email, password hash, and username. It will continue using `SQLAlchemyUserRepository.save()`, which creates the required default `user_profiles`, `user_settings`, and `user_security` records alongside a new user.
+
+### Profile population
+
+The seed repository contract will expose profile persistence. After saving a new user, the seeder will save a `UserProfile` whose `display_name` contains the configured full name. `SEED_ADMIN_FULLNAME` remains unchanged for backward compatibility. Development-user full names follow the same mapping.
+
+The profile is saved before assigning the role. All operations remain within the seed runner's existing transaction, so a failure rolls back the user, normalized related records, profile update, and role assignment together.
+
+### Idempotency
+
+Email remains the identity used to detect existing seed users. If a user already exists, the seeder will not update identity data, profile data, password hashes, settings, security state, or role assignments. Authorization resource, role, permission, role-permission, and Casbin policy seeding retains its existing idempotency checks.
+
+### Configuration
+
+No environment-variable names change. The relevant variables remain:
+
+- `SEED_ADMIN_EMAIL`
+- `SEED_ADMIN_PASSWORD`
+- `SEED_ADMIN_USERNAME`
+- `SEED_ADMIN_FULLNAME`
+- `SEED_DEVELOPMENT_USERS_PASSWORD`
+
+`SEED_ADMIN_FULLNAME` is documented as the value stored in `user_profiles.display_name`, not a column in `users`.
+
+## Testing
+
+Focused unit tests will cover:
+
+- Creating an admin user with current `User.create()` arguments.
+- Saving the admin display name to a normalized profile.
+- Creating development users and their profiles only in development mode.
+- Skipping user and profile writes when seed credentials are missing or the email exists.
+- Assigning the configured role only after a new user and profile are saved.
+- Returning accurate user and role counters.
+
+Existing project tests and Ruff checks will be run after implementation. Database-backed execution will be used only if the configured local services are available; otherwise unit tests will verify the seeder's behavior through its repository and authorization contracts.
+
+## README Changes
+
+The database-seeding section will explain:
+
+- The authorization records created by the baseline seed.
+- The normalized user records created for each new seeded user: `users`, `user_profiles`, `user_settings`, and `user_security`.
+- The mapping from `SEED_ADMIN_FULLNAME` to `user_profiles.display_name`.
+- Development-only demo accounts and their roles.
+- Existing-user behavior and transactional/idempotent guarantees.
+
+## Scope
+
+This change does not alter migrations, database models, seed account credentials, authorization definitions, or existing-user reconciliation. It fixes user seed compatibility with the current schema and documents actual behavior.
diff --git a/docs/superpowers/specs/2026-06-22-restore-database-foreign-keys-design.md b/docs/superpowers/specs/2026-06-22-restore-database-foreign-keys-design.md
new file mode 100644
index 0000000..fc9597f
--- /dev/null
+++ b/docs/superpowers/specs/2026-06-22-restore-database-foreign-keys-design.md
@@ -0,0 +1,76 @@
+# Restore Database Foreign Keys Design
+
+## Goal
+
+Restore every foreign-key relationship removed or omitted by the normalized user-schema change so SQLAlchemy can configure all mappers, PostgreSQL enforces referential integrity, migrations remain safe for databases that already applied `e73c215d7221`, and `make seed` completes successfully.
+
+## Root Cause
+
+The current ORM models declare relationship properties but no `ForeignKey` metadata. SQLAlchemy therefore cannot infer joins such as `permissions.id` to `role_permissions.permission_id`, causing mapper configuration to fail before the seed query executes.
+
+The latest normalization migration was generated from that incomplete metadata. It drops six established constraints:
+
+- `permissions.resource_id` to `authorization_resources.id`
+- `role_permissions.role_id` to `roles.id`
+- `role_permissions.permission_id` to `permissions.id`
+- `todos.user_id` to `users.id`
+- `user_has_roles.user_id` to `users.id`
+- `user_has_roles.role_id` to `roles.id`
+
+It also creates seven normalized user tables whose `user_id` columns are `VARCHAR(36)` even though `users.id` is UUID, and omits their intended constraints.
+
+## Design
+
+### ORM metadata
+
+Each affected model column will declare a SQLAlchemy `ForeignKey` targeting the parent table. Existing relationship names, `back_populates` pairs, collection shapes, and ORM cascade settings remain unchanged.
+
+The seven normalized user tables will use `Mapped[UUID]` and UUID-backed columns for `user_id`:
+
+- `user_profiles`
+- `user_security`
+- `user_settings`
+- `user_contacts`
+- `user_addresses`
+- `user_verifications`
+- `user_sessions`
+
+The authorization junction tables, permission resource link, todo owner link, and user-role junction table will retain their existing UUID Python types while gaining their missing `ForeignKey` declarations.
+
+No database-level delete cascade will be introduced. This restores the relationships that existed before normalization without adding new deletion behavior.
+
+### Model discovery
+
+Alembic must load every normalized user model before reading `Base.metadata`. Its environment will import the user model package, which already exports all eight user-domain models, instead of importing only `UserModel` and `UserSessionModel`. This keeps migration comparison aligned with runtime metadata.
+
+### Corrective migration
+
+A new Alembic revision after `e73c215d7221` will repair databases where the normalization migration has already run. The existing revision will not be rewritten.
+
+The corrective upgrade will:
+
+1. Convert the seven normalized `user_id` columns from `VARCHAR(36)` to UUID using an explicit PostgreSQL cast.
+2. Recreate the six constraints dropped by `e73c215d7221`.
+3. Add the seven missing normalized-user constraints.
+
+The migration will use deterministic constraint names. If an existing normalized `user_id` contains a malformed UUID or references a missing user, PostgreSQL will reject the migration. The migration must fail visibly rather than discard, rewrite, or detach invalid data.
+
+The downgrade will drop the 13 constraints added by the corrective revision and convert the seven normalized `user_id` columns back to `VARCHAR(36)`, returning the schema to the exact state represented by `e73c215d7221`.
+
+### Runtime data flow
+
+After mapper configuration succeeds, seeding keeps its current transaction flow: seed authorization resources, roles, permissions, and junction records; then seed users, normalized profile data, and role assignments. The fix changes schema metadata and integrity enforcement only. Seed definitions and idempotency behavior remain unchanged.
+
+## Testing and Verification
+
+Regression tests will import the complete model graph and assert that:
+
+- `configure_mappers()` completes without `NoForeignKeysError`.
+- All 13 child columns expose the expected foreign-key target in `Base.metadata`.
+- The seven normalized `user_id` columns use UUID rather than string types.
+
+The implementation will then run focused tests, the full test suite, Ruff, and Alembic migration checks. Against the configured local PostgreSQL service, verification will upgrade through the corrective revision and run `make seed`. A disposable database or reversible upgrade/downgrade cycle will be used for migration verification so application data is not destroyed.
+
+## Scope
+
+This change restores all affected foreign keys and corrects the normalized user identifier types. It does not change domain entities, repository APIs, seed contents, authorization policy definitions, indexes, uniqueness rules, or delete semantics.
diff --git a/poetry.lock b/poetry.lock
index b905eaa..01a5239 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -270,6 +270,46 @@ docs = ["Sphinx (>=3.3.1)", "doc8 (>=0.8.1)", "sphinx-rtd-theme (>=0.5.0)", "sph
linting = ["black", "isort", "pycodestyle"]
testing = ["pytest (>=6,!=7.0.0)", "pytest-xdist (>=2)"]
+[[package]]
+name = "boto3"
+version = "1.43.34"
+description = "The AWS SDK for Python"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "boto3-1.43.34-py3-none-any.whl", hash = "sha256:42595057324606928c6e2432b3093978e4d722e0d432bce942f2a385702c0a43"},
+ {file = "boto3-1.43.34.tar.gz", hash = "sha256:444207c6c883d4df3ea3b2c36df43ad492b86e0b889eebd2fc1d5ea8db0a8a1a"},
+]
+
+[package.dependencies]
+botocore = ">=1.43.34,<1.44.0"
+jmespath = ">=0.7.1,<2.0.0"
+s3transfer = ">=0.19.0,<0.20.0"
+
+[package.extras]
+crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
+
+[[package]]
+name = "botocore"
+version = "1.43.34"
+description = "Low-level, data-driven core of boto 3."
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "botocore-1.43.34-py3-none-any.whl", hash = "sha256:238a0269f33c5914b9343900b44767e783b3e8b6dcb6e065eac8b4495601c5df"},
+ {file = "botocore-1.43.34.tar.gz", hash = "sha256:ccc973cf30c6445b30afe5760f6dc949a80f1f862cb23d9c45747f2c814ece77"},
+]
+
+[package.dependencies]
+jmespath = ">=0.7.1,<2.0.0"
+python-dateutil = ">=2.1,<3.0.0"
+urllib3 = ">=1.25.4,<2.2.0 || >2.2.0,<3"
+
+[package.extras]
+crt = ["awscrt (==0.32.2)"]
+
[[package]]
name = "cachecontrol"
version = "0.14.4"
@@ -562,7 +602,7 @@ version = "8.4.1"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.10"
-groups = ["main"]
+groups = ["main", "dev"]
files = [
{file = "click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2"},
{file = "click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96"},
@@ -578,11 +618,11 @@ description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["main", "dev"]
+markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
-markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\"", dev = "sys_platform == \"win32\""}
[[package]]
name = "cryptography"
@@ -882,6 +922,124 @@ files = [
docs = ["Sphinx", "furo"]
test = ["objgraph", "psutil", "setuptools"]
+[[package]]
+name = "grimp"
+version = "3.14"
+description = "Builds a queryable graph of the imports within one or more Python packages."
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "grimp-3.14-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:17364365c27c111514fd9d17844f275ed074ec9feca0d6cf9bd5bf9218db2412"},
+ {file = "grimp-3.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:25273ea53ac1492e7343bd9d9d9b60445f707bc0d162eca85288c7325579ee47"},
+ {file = "grimp-3.14-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:53b8f69bdf070fddbbc13f60a5cdb42efb102516770b34f076456ec4ce960627"},
+ {file = "grimp-3.14-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1aa397596bb6d616200be1fd6570e87ddc225c192845c649d4f6015175b77bc6"},
+ {file = "grimp-3.14-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2892ca934fc19c6d51d6c0a609d4db7e97c4721cc9a609f2bab8fe8e1ec1821"},
+ {file = "grimp-3.14-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e9367b9fa9c97cb8d1974a164d5981852b498977a097ad7335fc012ab96498b"},
+ {file = "grimp-3.14-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87f398915c716c13736460a54f8dc5d70494d7d616039f547c0093f252307109"},
+ {file = "grimp-3.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5551a825b14e52642428ef7c4a5790819bfaee0fdae94f89ce248cff3d7109bb"},
+ {file = "grimp-3.14-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6ee7a2fab52ce0c6ae81fa1f2319bad5bd361110994567477f26be018043d63d"},
+ {file = "grimp-3.14-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:6d1434172a02cd97425126260dec80a8fd0491d9467b822d871498199c296c91"},
+ {file = "grimp-3.14-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9a85bf0a8c4b58db12184fe53a469a7189b4c63397a2eaca0d9efe410f6f68e7"},
+ {file = "grimp-3.14-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:53d9ed23fb7da4c886affeb6b8bce7c19d8b09f2e1631a482c9446a20d504bdf"},
+ {file = "grimp-3.14-cp310-cp310-win32.whl", hash = "sha256:d05110b9afda361ff8d90740a8344ccfd2d59a5a1977d517b9bce178738ed34f"},
+ {file = "grimp-3.14-cp310-cp310-win_amd64.whl", hash = "sha256:fad2a819756b5c0441b8841c2e6f541960b13edd09b672e6e199232dcf9bcb7a"},
+ {file = "grimp-3.14-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f1c91e3fa48c2196bf62e3c71492140d227b2bfcd6d15e735cbc0b3e2d5308e0"},
+ {file = "grimp-3.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6291c8f1690a9fe21b70923c60b075f4a89676541999e3d33084cbc69ac06a1"},
+ {file = "grimp-3.14-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ec312383935c2d09e4085c8435780ada2e13ebef14e105609c2988a02a5b2ce"},
+ {file = "grimp-3.14-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4f43cbf640e73ee703ad91639591046828d20103a1c363a02516e77a66a4ac07"},
+ {file = "grimp-3.14-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2a93c9fddccb9ff16f5c6b5fca44227f5f86cba7cffc145d2176119603d2d7c7"},
+ {file = "grimp-3.14-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5653a2769fdc062cb7598d12200352069c9c6559b6643af6ada3639edb98fcc3"},
+ {file = "grimp-3.14-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:071c7ddf5e5bb7b2fdf79aefdf6e1c237cd81c095d6d0a19620e777e85bf103c"},
+ {file = "grimp-3.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e01b7a4419f535b667dfdcb556d3815b52981474f791fb40d72607228389a31"},
+ {file = "grimp-3.14-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c29682f336151d1d018d0c3aa9eeaa35734b970e4593fa396b901edca7ef5c79"},
+ {file = "grimp-3.14-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:a5c4fd71f363ea39e8aab0630010ced77a8de9789f27c0acdd0d7e6269d4a8ef"},
+ {file = "grimp-3.14-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:766911e3ba0b13d833fdd03ad1f217523a8a2b2527b5507335f71dca1153183d"},
+ {file = "grimp-3.14-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:154e84a2053e9f858ae48743de23a5ad4eb994007518c29371276f59b8419036"},
+ {file = "grimp-3.14-cp311-cp311-win32.whl", hash = "sha256:3189c86c3e73016a1907ee3ba9f7a6ca037e3601ad09e60ce9bf12b88877f812"},
+ {file = "grimp-3.14-cp311-cp311-win_amd64.whl", hash = "sha256:201f46a6a4e5ee9dfba4a2f7d043f7deab080d1d84233f4a1aee812678c25307"},
+ {file = "grimp-3.14-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ffabc6940301214753bad89ec0bfe275892fa1f64b999e9a101f6cebfc777133"},
+ {file = "grimp-3.14-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:075d9a1c78d607792d0ed8d4d3d7754a621ef04c8a95eaebf634930dc9232bb2"},
+ {file = "grimp-3.14-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06ff52addeb20955a4d6aa097bee910573ffc9ef0d3c8a860844f267ad958156"},
+ {file = "grimp-3.14-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d10e0663e961fcbe8d0f54608854af31f911f164c96a44112d5173050132701f"},
+ {file = "grimp-3.14-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4ab874d7ddddc7a1291259cf7c31a4e7b5c612e9da2e24c67c0eb1a44a624e67"},
+ {file = "grimp-3.14-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54fec672ec83355636a852177f5a470c964bede0f6730f9ba3c7b5c8419c9eab"},
+ {file = "grimp-3.14-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b9e221b5e8070a916c780e88c877fee2a61c95a76a76a2a076396e459511b0bb"},
+ {file = "grimp-3.14-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eea6b495f9b4a8d82f5ce544921e76d0d12017f5d1ac3a3bd2f5ac88ab055b1c"},
+ {file = "grimp-3.14-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:655e8d3f79cd99bb859e09c9dd633515150e9d850879ca71417d5ac31809b745"},
+ {file = "grimp-3.14-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a14f10b1b71c6c37647a76e6a49c226509648107abc0f48c1e3ecd158ba05531"},
+ {file = "grimp-3.14-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:81685111ee24d3e25f8ed9e77ed00b92b58b2414e1a1c2937236026900972744"},
+ {file = "grimp-3.14-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ce8352a8ea0e27b143136ea086582fc6653419aa8a7c15e28ed08c898c42b185"},
+ {file = "grimp-3.14-cp312-cp312-win32.whl", hash = "sha256:3fc0f98b3c60d88e9ffa08faff3200f36604930972f8b29155f323b76ea25a06"},
+ {file = "grimp-3.14-cp312-cp312-win_amd64.whl", hash = "sha256:6bca77d1d50c8dc402c96af21f4e28e2f1e9938eeabd7417592a22bd83cde3c3"},
+ {file = "grimp-3.14-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:af8a625554beea84530b98cc471902155b5fc042b42dc47ec846fa3e32b0c615"},
+ {file = "grimp-3.14-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0dd1942ffb419ad342f76b0c3d3d2d7f312b264ddc578179d13ce8d5acec1167"},
+ {file = "grimp-3.14-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:537f784ce9b4acf8657f0b9714ab69a6c72ffa752eccc38a5a85506103b1a194"},
+ {file = "grimp-3.14-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78ab18c08770aa005bef67b873bc3946d33f65727e9f3e508155093db5fa57d6"},
+ {file = "grimp-3.14-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28ca58728c27e7292c99f964e6ece9295c2f9cfdefc37c18dea0679c783ffb6f"},
+ {file = "grimp-3.14-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b5577de29c6c5ae6e08d4ca0ac361b45dba323aa145796e6b320a6ea35414b7"},
+ {file = "grimp-3.14-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d7d1f9f42306f455abcec34db877e4887ff15f2777a43491f7ccbd6936c449b"},
+ {file = "grimp-3.14-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39bd5c9b7cef59ee30a05535e9cb4cbf45a3c503f22edce34d0aa79362a311a9"},
+ {file = "grimp-3.14-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fec3116b4f780a1bc54176b19e6b9f2e36e2ef3164b8fc840660566af35df88"},
+ {file = "grimp-3.14-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:0233a35a5bbb23688d63e1736b54415fa9994ace8dfeb7de8514ed9dee212968"},
+ {file = "grimp-3.14-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e46b2fef0f1da7e7e2f8129eb93c7e79db716ff7810140a22ce5504e10ed86df"},
+ {file = "grimp-3.14-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3e6d9b50623ee1c3d2a1927ec3f5d408995ea1f92f3e91ed996c908bb40e856f"},
+ {file = "grimp-3.14-cp313-cp313-win32.whl", hash = "sha256:fd57c56f5833c99320ec77e8ba5508d56f6fb48ec8032a942f7931cc6ebb80ce"},
+ {file = "grimp-3.14-cp313-cp313-win_amd64.whl", hash = "sha256:173307cf881a126fe5120b7bbec7d54384002e3c83dcd8c4df6ce7f0fee07c53"},
+ {file = "grimp-3.14-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ebe29f8f13fbd7c314908ed535183a36e6db71839355b04869b27f23c58fa082"},
+ {file = "grimp-3.14-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:073d285b00100153fd86064c7726bb1b6d610df1356d33bb42d3fd8809cb6e72"},
+ {file = "grimp-3.14-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f6d6efc37e1728bbfcd881b89467be5f7b046292597b3ebe5f8e44e89ea8b6cb"},
+ {file = "grimp-3.14-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5337d65d81960b712574c41e85b480d4480bbb5c6f547c94e634f6c60d730889"},
+ {file = "grimp-3.14-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:84a7fea63e352b325daa89b0b7297db411b7f0036f8d710c32f8e5090e1fc3ca"},
+ {file = "grimp-3.14-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:d0b19a3726377165fe1f7184a8af317734d80d32b371b6c5578747867ab53c0b"},
+ {file = "grimp-3.14-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9caa4991f530750f88474a3f5ecf6ef9f0d064034889d92db00cfb4ecb78aa24"},
+ {file = "grimp-3.14-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1876efc119b99332a5cc2b08a6bdaada2f0ad94b596f0372a497e2aa8bda4d94"},
+ {file = "grimp-3.14-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3ccf03e65864d6bc7bf1c003c319f5330a7627b3677f31143f11691a088464c2"},
+ {file = "grimp-3.14-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9ecd58fa58a270e7523f8bec9e6452f4fdb9c21e4cd370640829f1e43fa87a69"},
+ {file = "grimp-3.14-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d75d1f8f7944978b39b08d870315174f1ffcd5123be6ccff8ce90467ace648a"},
+ {file = "grimp-3.14-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f70bbb1dd6055d08d29e39a78a11c4118c1778b39d17cd8271e18e213524ca7"},
+ {file = "grimp-3.14-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0f21b7c003626c902669dc26ede83a91220cf0a81b51b27128370998c2f247b4"},
+ {file = "grimp-3.14-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80d9f056415c936b45561310296374c4319b5df0003da802c84d2830a103792a"},
+ {file = "grimp-3.14-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0332963cd63a45863775d4237e59dedf95455e0a1ea50c356be23100c5fc1d7c"},
+ {file = "grimp-3.14-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f4144350d074f2058fe7c89230a26b34296b161f085b0471a692cb2fe27036f"},
+ {file = "grimp-3.14-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e148e67975e92f90a8435b1b4c02180b9a3f3d725b7a188ba63793f1b1e445a0"},
+ {file = "grimp-3.14-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1093f7770cb5f3ca6f99fb152f9c949381cc0b078dfdfe598c8ab99abaccda3b"},
+ {file = "grimp-3.14-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a213f45ec69e9c2b28ffd3ba5ab12cc9859da17083ba4dc39317f2083b618111"},
+ {file = "grimp-3.14-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f003ac3f226d2437a49af0b6036f26edba57f8a32d329275dbde1b2b2a00a56"},
+ {file = "grimp-3.14-cp314-cp314-win32.whl", hash = "sha256:eec81be65a18f4b2af014b1e97296cc9ee20d1115529bf70dd7e06f457eac30b"},
+ {file = "grimp-3.14-cp314-cp314-win_amd64.whl", hash = "sha256:cd3bab6164f1d5e313678f0ab4bf45955afe7f5bdb0f2f481014aa9cca7e81ba"},
+ {file = "grimp-3.14-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b1df33de479be4d620f69633d1876858a8e64a79c07907d47cf3aaf896af057"},
+ {file = "grimp-3.14-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07096d4402e9d5a2c59c402ea3d601f4b7f99025f5e32f077468846fc8d3821b"},
+ {file = "grimp-3.14-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:712bc28f46b354316af50c469c77953ba3d6cb4166a62b8fb086436a8b05d301"},
+ {file = "grimp-3.14-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abe2bbef1cf8e27df636c02f60184319f138dee4f3a949405c21a4b491980397"},
+ {file = "grimp-3.14-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2f9ae3fabb7a7a8468ddc96acc84ecabd84f168e7ca508ee94d8f32ea9bd5de2"},
+ {file = "grimp-3.14-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:efaf11ea73f7f12d847c54a5d6edcbe919e0369dce2d1aabae6c50792e16f816"},
+ {file = "grimp-3.14-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e089c9ab8aa755ff5af88c55891727783b4eb6b228e7bdf278e17209d954aa1e"},
+ {file = "grimp-3.14-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a424ad14d5deb56721ac24ab939747f72ab3d378d42e7d1f038317d33b052b77"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1d4f96c0159b33647295ad36683fe7be55fa620de6e54e970c913cb88d0a5a6"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e715f78fda0019b493459f97efc48462912b4c5b5d261215d94c05115511d311"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d0a885b04edbe908cd6f2f8cb0999dd2a348091d241bd9842f9ea593fabdce5"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c6995b20574313ba66b73d288f431af24b9d23d60c861e8f5cbf0d0e26ad9c49"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:d2a170deb9f4790221dcde8c47e60be7fcd52999062241ac944ce556efa1d24d"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:1d4a28e2545a83c853a6357ccf4a5105e3f74419a75312b5ebaf0435085cd938"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:9aa74d848c083725add12e0e6d42a01ddfd8ee84e9504ad7254204985e3c5c92"},
+ {file = "grimp-3.14-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:acf0acedaf105c8d3747abf073c6a2dd1379bafcb5807926fd6d5fe4b0980698"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7c8a8aab9b4310a7e69d7d845cac21cf14563aa0520ea322b948eadeae56d303"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d781943b27e5875a41c8f9cfc80f8f0a349f864379192b8c3faa0e6a22593313"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9630d4633607aff94d0ac84b9c64fef1382cdb05b00d9acbde47f8745e264871"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7cb00e1bcca583668554a8e9e1e4229a1d11b0620969310aae40148829ff6a32"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3389da4ceaaa7f7de24a668c0afc307a9f95997bd90f81ec359a828a9bd1d270"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cd7a32970ef97e42d4e7369397c7795287d84a736d788ccb90b6c14f0561d975"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:fd1278623fa09f62abc0fd8a6500f31b421a1fd479980f44c2926020a0becf02"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:9cfa52c89333d3d8fe9dc782529e888270d060231c3783e036d424044671dde0"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:48a5be4a12fca6587e6885b4fc13b9e242ab8bf874519292f0f13814aecf52cc"},
+ {file = "grimp-3.14-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:3fcc332466783a12a42cd317fd344c30fe734ba4fa2362efff132dc3f8d36da7"},
+ {file = "grimp-3.14.tar.gz", hash = "sha256:645fbd835983901042dae4e1b24fde3a89bf7ac152f9272dd17a97e55cb4f871"},
+]
+
+[package.dependencies]
+typing-extensions = ">=3.10.0.0"
+
[[package]]
name = "h11"
version = "0.16.0"
@@ -1063,6 +1221,27 @@ files = [
[package.extras]
all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+[[package]]
+name = "import-linter"
+version = "2.11"
+description = "Lint your Python architecture"
+optional = false
+python-versions = ">=3.10"
+groups = ["dev"]
+files = [
+ {file = "import_linter-2.11-py3-none-any.whl", hash = "sha256:3dc54cae933bae3430358c30989762b721c77aa99d424f56a08265be0eeaa465"},
+ {file = "import_linter-2.11.tar.gz", hash = "sha256:5abc3394797a54f9bae315e7242dc98715ba485f840ac38c6d3192c370d0085e"},
+]
+
+[package.dependencies]
+click = ">=6"
+grimp = ">=3.14"
+rich = ">=14.2.0"
+typing-extensions = ">=3.10.0.0"
+
+[package.extras]
+ui = ["fastapi (>=0.113)", "uvicorn (>=0.17.1)"]
+
[[package]]
name = "iniconfig"
version = "2.3.0"
@@ -1075,6 +1254,36 @@ files = [
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
+[[package]]
+name = "jinja2"
+version = "3.1.6"
+description = "A very fast and expressive template engine."
+optional = false
+python-versions = ">=3.7"
+groups = ["main"]
+files = [
+ {file = "jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67"},
+ {file = "jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d"},
+]
+
+[package.dependencies]
+MarkupSafe = ">=2.0"
+
+[package.extras]
+i18n = ["Babel (>=2.7)"]
+
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+description = "JSON Matching Expressions"
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64"},
+ {file = "jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d"},
+]
+
[[package]]
name = "librt"
version = "0.11.0"
@@ -1244,7 +1453,7 @@ version = "3.0.3"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.9"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
@@ -1576,6 +1785,115 @@ hyperscan = ["hyperscan (>=0.7)"]
optional = ["typing-extensions (>=4)"]
re2 = ["google-re2 (>=1.1)"]
+[[package]]
+name = "pillow"
+version = "12.2.0"
+description = "Python Imaging Library (fork)"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"},
+ {file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"},
+ {file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"},
+ {file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"},
+ {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"},
+ {file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"},
+ {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"},
+ {file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"},
+ {file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"},
+ {file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"},
+ {file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"},
+ {file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"},
+ {file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"},
+ {file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"},
+ {file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"},
+ {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"},
+ {file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"},
+ {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"},
+ {file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"},
+ {file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"},
+ {file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"},
+ {file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"},
+ {file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"},
+ {file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"},
+ {file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"},
+ {file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"},
+ {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"},
+ {file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"},
+ {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"},
+ {file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"},
+ {file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"},
+ {file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"},
+ {file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"},
+ {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"},
+ {file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"},
+ {file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"},
+ {file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"},
+ {file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"},
+ {file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"},
+ {file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"},
+ {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"},
+ {file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"},
+ {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"},
+ {file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"},
+ {file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"},
+ {file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"},
+ {file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"},
+ {file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"},
+ {file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"},
+ {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"},
+ {file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"},
+ {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"},
+ {file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"},
+ {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"},
+ {file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"},
+ {file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"},
+ {file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"},
+ {file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"},
+ {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"},
+ {file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"},
+ {file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"},
+ {file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"},
+ {file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"},
+ {file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"},
+ {file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"},
+ {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"},
+ {file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"},
+ {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"},
+ {file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"},
+ {file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"},
+ {file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"},
+ {file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"},
+ {file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"},
+ {file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"},
+ {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"},
+ {file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"},
+ {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"},
+ {file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"},
+ {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"},
+ {file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"},
+ {file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"},
+ {file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"},
+ {file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"},
+ {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"},
+ {file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"},
+ {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"},
+ {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"},
+ {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"},
+ {file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"},
+ {file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"},
+ {file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"},
+]
+
+[package.extras]
+docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
+fpx = ["olefile"]
+mic = ["olefile"]
+test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
+tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
+xmp = ["defusedxml"]
+
[[package]]
name = "pip"
version = "26.1.2"
@@ -1917,6 +2235,21 @@ files = [
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
+[[package]]
+name = "pyotp"
+version = "2.10.0"
+description = "Python One Time Password Library"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "pyotp-2.10.0-py3-none-any.whl", hash = "sha256:1df2f6a1bcc3bb0716172a5215ddc2f8c7c7fd26a13df9927d52e1746934836c"},
+ {file = "pyotp-2.10.0.tar.gz", hash = "sha256:d01e9703443616b03c57c700b5cbffd56a1f929c1b0f8f03131bc78c1fca9d3f"},
+]
+
+[package.extras]
+test = ["coverage", "mypy", "ruff", "wheel"]
+
[[package]]
name = "pyparsing"
version = "3.3.2"
@@ -1954,6 +2287,21 @@ pygments = ">=2.7.2"
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+description = "Extensions to the standard Python datetime module"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"},
+ {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"},
+]
+
+[package.dependencies]
+six = ">=1.5"
+
[[package]]
name = "python-dotenv"
version = "1.2.2"
@@ -1969,6 +2317,18 @@ files = [
[package.extras]
cli = ["click (>=5.0)"]
+[[package]]
+name = "python-http-client"
+version = "3.3.7"
+description = "HTTP REST client, simplified for Python"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+groups = ["main"]
+files = [
+ {file = "python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36"},
+ {file = "python_http_client-3.3.7.tar.gz", hash = "sha256:bf841ee45262747e00dec7ee9971dfb8c7d83083f5713596488d67739170cea0"},
+]
+
[[package]]
name = "python-jose"
version = "3.5.0"
@@ -2088,6 +2448,27 @@ files = [
{file = "pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f"},
]
+[[package]]
+name = "qrcode"
+version = "8.2"
+description = "QR Code image generator"
+optional = false
+python-versions = "<4.0,>=3.9"
+groups = ["main"]
+files = [
+ {file = "qrcode-8.2-py3-none-any.whl", hash = "sha256:16e64e0716c14960108e85d853062c9e8bba5ca8252c0b4d0231b9df4060ff4f"},
+ {file = "qrcode-8.2.tar.gz", hash = "sha256:35c3f2a4172b33136ab9f6b3ef1c00260dd2f66f858f24d88418a015f446506c"},
+]
+
+[package.dependencies]
+colorama = {version = "*", markers = "sys_platform == \"win32\""}
+pillow = {version = ">=9.1.0", optional = true, markers = "extra == \"pil\" or extra == \"all\""}
+
+[package.extras]
+all = ["pillow (>=9.1.0)", "pypng"]
+pil = ["pillow (>=9.1.0)"]
+png = ["pypng"]
+
[[package]]
name = "redis"
version = "8.0.0"
@@ -2192,6 +2573,41 @@ files = [
{file = "ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219"},
]
+[[package]]
+name = "s3transfer"
+version = "0.19.0"
+description = "An Amazon S3 Transfer Manager"
+optional = false
+python-versions = ">=3.10"
+groups = ["main"]
+files = [
+ {file = "s3transfer-0.19.0-py3-none-any.whl", hash = "sha256:777cc2415536f1debadb5c2ef7779275d0fc0fe0e042411cdd6caebeb2685262"},
+ {file = "s3transfer-0.19.0.tar.gz", hash = "sha256:ce436931687addc4c1712d52d40b32f53e88315723f107ffa20ba82b05a0f685"},
+]
+
+[package.dependencies]
+botocore = ">=1.37.4,<2.0a0"
+
+[package.extras]
+crt = ["botocore[crt] (>=1.37.4,<2.0a0)"]
+
+[[package]]
+name = "sendgrid"
+version = "6.12.5"
+description = "Twilio SendGrid library for Python"
+optional = false
+python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7"
+groups = ["main"]
+files = [
+ {file = "sendgrid-6.12.5-py3-none-any.whl", hash = "sha256:96f92cc91634bf552fdb766b904bbb53968018da7ae41fdac4d1090dc0311ca8"},
+ {file = "sendgrid-6.12.5.tar.gz", hash = "sha256:ea9aae30cd55c332e266bccd11185159482edfc07c149b6cd15cf08869fabdb7"},
+]
+
+[package.dependencies]
+cryptography = ">=45.0.6"
+python-http-client = ">=3.2.1"
+werkzeug = {version = ">=2.3.5", markers = "python_version >= \"3.12\""}
+
[[package]]
name = "simpleeval"
version = "1.0.7"
@@ -2457,7 +2873,7 @@ version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.10"
-groups = ["dev"]
+groups = ["main", "dev"]
files = [
{file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
{file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
@@ -2751,7 +3167,25 @@ files = [
{file = "websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5"},
]
+[[package]]
+name = "werkzeug"
+version = "3.1.8"
+description = "The comprehensive WSGI web application library."
+optional = false
+python-versions = ">=3.9"
+groups = ["main"]
+files = [
+ {file = "werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50"},
+ {file = "werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44"},
+]
+
+[package.dependencies]
+markupsafe = ">=2.1.1"
+
+[package.extras]
+watchdog = ["watchdog (>=2.3)"]
+
[metadata]
lock-version = "2.1"
python-versions = ">=3.14,<4.0"
-content-hash = "386eb123be98db5062176d5524eccc806deb67527587294ef63c31383f686734"
+content-hash = "56b1835062b4b9993a80c967d7ee5b778be90d43770f1bd28e5faf9cf4b2c85b"
diff --git a/pyproject.toml b/pyproject.toml
index fa5d65a..da526b0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -19,7 +19,12 @@ dependencies = [
"greenlet (>=3.5.1,<4.0.0)",
"casbin (>=1.43.0,<2.0.0)",
"fastapi-limiter (==0.1.6)",
- "redis (>=8.0.0,<9.0.0)"
+ "redis (>=8.0.0,<9.0.0)",
+ "jinja2 (>=3.1.6,<4.0.0)",
+ "boto3 (>=1.43.34,<2.0.0)",
+ "sendgrid (>=6.12.5,<7.0.0)",
+ "pyotp (>=2.10.0,<3.0.0)",
+ "qrcode[pil] (>=8.2,<9.0)"
]
[tool.poetry]
@@ -39,5 +44,9 @@ dev = [
"mypy (>=2.1.0,<3.0.0)",
"alembic (>=1.18.4,<2.0.0)",
"httpx2 (>=2.4.0,<3.0.0)",
- "pip-audit (>=2.10.1,<3.0.0)"
+ "pip-audit (>=2.10.1,<3.0.0)",
+ "import-linter (>=2.11,<3.0)"
]
+
+[tool.ruff]
+lint.ignore = ["F821"]
diff --git a/src/core/authorization/infrastructure/models/permission_model.py b/src/core/authorization/infrastructure/models/permission_model.py
deleted file mode 100644
index fe816aa..0000000
--- a/src/core/authorization/infrastructure/models/permission_model.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from uuid import UUID
-
-from sqlalchemy import ForeignKey, String, UniqueConstraint
-from sqlalchemy.orm import Mapped, mapped_column
-
-from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
-from src.shared.database.model import Base
-
-
-class PermissionModel(Base, TimeStampMixin, SoftDeleteMixin):
- __tablename__ = "permissions"
- __table_args__ = (
- UniqueConstraint("resource", "action", name="uq_permissions_resource_action"),
- )
-
- key: Mapped[str] = mapped_column(String(255), unique=True, index=True)
- resource_id: Mapped[UUID] = mapped_column(
- ForeignKey("authorization_resources.id"),
- index=True,
- )
- resource: Mapped[str] = mapped_column(String(100), index=True)
- action: Mapped[str] = mapped_column(String(100), index=True)
- description: Mapped[str | None] = mapped_column(String(255), nullable=True)
diff --git a/src/core/authorization/infrastructure/models/resource_model.py b/src/core/authorization/infrastructure/models/resource_model.py
deleted file mode 100644
index 95c2082..0000000
--- a/src/core/authorization/infrastructure/models/resource_model.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from sqlalchemy import String
-from sqlalchemy.orm import Mapped, mapped_column
-
-from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
-from src.shared.database.model import Base
-
-
-class AuthorizationResourceModel(Base, TimeStampMixin, SoftDeleteMixin):
- __tablename__ = "authorization_resources"
-
- key: Mapped[str] = mapped_column(String(100), unique=True, index=True)
- name: Mapped[str] = mapped_column(String(150), nullable=False)
- description: Mapped[str | None] = mapped_column(String(255), nullable=True)
diff --git a/src/core/authorization/infrastructure/models/role_model.py b/src/core/authorization/infrastructure/models/role_model.py
deleted file mode 100644
index 7be2550..0000000
--- a/src/core/authorization/infrastructure/models/role_model.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from sqlalchemy import String
-from sqlalchemy.orm import Mapped, mapped_column
-
-from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
-from src.shared.database.model import Base
-
-
-class RoleModel(Base, TimeStampMixin, SoftDeleteMixin):
- __tablename__ = "roles"
-
- name: Mapped[str] = mapped_column(String(100), unique=True, index=True)
- description: Mapped[str | None] = mapped_column(String(255), nullable=True)
diff --git a/src/core/authorization/infrastructure/models/role_permission_model.py b/src/core/authorization/infrastructure/models/role_permission_model.py
deleted file mode 100644
index 1c92de1..0000000
--- a/src/core/authorization/infrastructure/models/role_permission_model.py
+++ /dev/null
@@ -1,23 +0,0 @@
-from uuid import UUID
-
-from sqlalchemy import ForeignKey, UniqueConstraint
-from sqlalchemy.orm import Mapped, mapped_column
-
-from src.shared.database.model import Base
-
-
-class RolePermissionModel(Base):
- __tablename__ = "role_permissions"
- __table_args__ = (
- UniqueConstraint(
- "role_id",
- "permission_id",
- name="uq_role_permissions_role_id_permission_id",
- ),
- )
-
- role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), index=True)
- permission_id: Mapped[UUID] = mapped_column(
- ForeignKey("permissions.id"),
- index=True,
- )
diff --git a/src/core/authorization/infrastructure/models/user_has_role_model.py b/src/core/authorization/infrastructure/models/user_has_role_model.py
deleted file mode 100644
index a67c1e0..0000000
--- a/src/core/authorization/infrastructure/models/user_has_role_model.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from uuid import UUID
-
-from sqlalchemy import ForeignKey, UniqueConstraint
-from sqlalchemy.orm import Mapped, mapped_column
-
-from src.shared.database.model import Base
-
-
-class UserHasRoleModel(Base):
- __tablename__ = "user_has_roles"
- __table_args__ = (
- UniqueConstraint("user_id", "role_id", name="uq_user_has_roles_user_id_role_id"),
- )
-
- user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id"), index=True)
- role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), index=True)
diff --git a/src/core/bootstrap/event.py b/src/core/bootstrap/event.py
new file mode 100644
index 0000000..870c945
--- /dev/null
+++ b/src/core/bootstrap/event.py
@@ -0,0 +1,25 @@
+from src.core.email.factory import create_email_service
+from src.core.events.bus import EventBus, get_event_bus
+from src.modules.user.application.events.emails.handler import (
+ SendPasswordResetEmailHandler,
+ SendVerificationEmailHandler,
+ SendWelcomeEmailHandler,
+)
+from src.modules.user.domain.events.emails.event import (
+ PasswordResetRequestedEvent,
+ UserRegisteredEvent,
+ WelcomeEmailEvent,
+)
+
+
+def register_event_handlers(bus: EventBus | None = None) -> None:
+ bus = bus or get_event_bus()
+ email_service = create_email_service()
+ bus.subscribe(
+ UserRegisteredEvent.__name__, SendVerificationEmailHandler(email_service)
+ )
+ bus.subscribe(
+ PasswordResetRequestedEvent.__name__,
+ SendPasswordResetEmailHandler(email_service),
+ )
+ bus.subscribe(WelcomeEmailEvent.__name__, SendWelcomeEmailHandler(email_service))
diff --git a/src/core/config/__init__.py b/src/core/config/__init__.py
new file mode 100644
index 0000000..6997031
--- /dev/null
+++ b/src/core/config/__init__.py
@@ -0,0 +1,5 @@
+from src.core.config.setting import get_settings
+
+settings = get_settings()
+
+__all__ = ["settings"]
diff --git a/src/core/config/setting.py b/src/core/config/setting.py
index 466ff65..c17efce 100644
--- a/src/core/config/setting.py
+++ b/src/core/config/setting.py
@@ -1,5 +1,5 @@
from functools import lru_cache
-from typing import ClassVar
+from typing import ClassVar, Optional
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -8,16 +8,28 @@
class Settings(BaseSettings):
DEFAULT_SECRET_KEY: ClassVar[str] = "super-secret-key-change-in-production"
+ # Application metadata and runtime environment.
APP_NAME: str = Field(alias="APP_NAME", default="Todo Modulith API")
APP_ENV: str = Field(alias="APP_ENV", default="development")
+ FRONTEND_URL: str = Field(alias="FRONTEND_URL", default="http://localhost:3000")
+
+ # Database connection string and SQLAlchemy pool tuning.
DATABASE_URL: str = Field(
alias="DATABASE_URL",
default="postgresql+asyncpg://postgres@localhost:5432/todo_db",
)
+ DATABASE_POOL_SIZE: int = Field(alias="DATABASE_POOL_SIZE", default=20)
+ DATABASE_MAX_OVERFLOW: int = Field(alias="DATABASE_MAX_OVERFLOW", default=10)
+ DATABASE_POOL_TIMEOUT: int = Field(alias="DATABASE_POOL_TIMEOUT", default=30)
+ DATABASE_POOL_RECYCLE: int = Field(alias="DATABASE_POOL_RECYCLE", default=3600)
+
+ # Redis connection used by shared infrastructure such as rate limiting or caching.
REDIS_URL: str = Field(
alias="REDIS_URL",
default="redis://127.0.0.1:6379/0",
)
+
+ # JWT signing, validation, and token lifetime settings.
SECRET_KEY: str = Field(alias="SECRET_KEY", default=DEFAULT_SECRET_KEY)
ALGORITHM: str = Field(alias="ALGORITHM", default="HS256")
JWT_ISSUER: str = Field(alias="JWT_ISSUER", default="todo-modulith-api")
@@ -28,6 +40,8 @@ class Settings(BaseSettings):
REFRESH_TOKEN_EXPIRE_MINUTES: int = Field(
alias="REFRESH_TOKEN_EXPIRE_MINUTES", default=10080
)
+
+ # HTTP protection settings for rate limits, CORS, CSP, idempotency, and payload size.
RATE_LIMIT: str = Field(alias="RATE_LIMIT", default="100/minute")
CORS_ALLOW_ORIGINS: str = Field(
alias="CORS_ALLOW_ORIGINS",
@@ -40,6 +54,11 @@ class Settings(BaseSettings):
default="default-src 'self'; frame-ancestors 'none'",
)
IDEMPOTENCY_TTL_SECONDS: int = Field(alias="IDEMPOTENCY_TTL_SECONDS", default=86400)
+ MAX_REQUEST_SIZE_MB: int = Field(
+ alias="MAX_REQUEST_SIZE_MB", default=5 * 1024 * 1024
+ )
+
+ # Account lockout thresholds used to slow repeated failed login attempts.
ACCOUNT_LOCKOUT_MAX_ATTEMPTS: int = Field(
alias="ACCOUNT_LOCKOUT_MAX_ATTEMPTS", default=5
)
@@ -49,7 +68,38 @@ class Settings(BaseSettings):
ACCOUNT_LOCKOUT_DURATION_MINUTES: int = Field(
alias="ACCOUNT_LOCKOUT_DURATION_MINUTES", default=15
)
+
+ # Logging output format for application logs.
LOG_FORMAT: str = Field(alias="LOG_FORMAT", default="json")
+
+ # Email provider selection and provider-specific credentials.
+ EMAIL_PROVIDER: str = Field(alias="EMAIL_PROVIDER", default="ses")
+
+ # AWS SES configuration.
+ AWS_REGION: str = Field(alias="AWS_REGION", default="us-east-1")
+ AWS_ACCESS_KEY_ID: Optional[str] = Field(alias="AWS_ACCESS_KEY_ID", default=None)
+ AWS_SECRET_ACCESS_KEY: Optional[str] = Field(
+ alias="AWS_SECRET_ACCESS_KEY",
+ default=None,
+ )
+ SES_FROM_EMAIL: str = Field(alias="SES_FROM_EMAIL", default="noreply@example.com")
+
+ # SendGrid configuration.
+ SENDGRID_API_KEY: Optional[str] = Field(alias="SENDGRID_API_KEY", default=None)
+ SENDGRID_FROM_EMAIL: str = Field(
+ alias="SENDGRID_FROM_EMAIL",
+ default="noreply@example.com",
+ )
+
+ # SMTP configuration for Gmail or other SMTP providers.
+ SMTP_HOST: Optional[str] = Field(alias="SMTP_HOST", default=None)
+ SMTP_PORT: int = Field(alias="SMTP_PORT", default=587)
+ SMTP_USERNAME: Optional[str] = Field(alias="SMTP_USERNAME", default=None)
+ SMTP_PASSWORD: Optional[str] = Field(alias="SMTP_PASSWORD", default=None)
+ SMTP_FROM_EMAIL: str = Field(alias="SMTP_FROM_EMAIL", default="noreply@example.com")
+ SMTP_USE_TLS: bool = Field(alias="SMTP_USE_TLS", default=True)
+
+ # Optional admin and development users created by database seeders.
SEED_ADMIN_EMAIL: str = Field(alias="SEED_ADMIN_EMAIL", default="")
SEED_ADMIN_PASSWORD: str = Field(alias="SEED_ADMIN_PASSWORD", default="")
SEED_ADMIN_USERNAME: str = Field(alias="SEED_ADMIN_USERNAME", default="admin")
@@ -61,13 +111,6 @@ class Settings(BaseSettings):
alias="SEED_DEVELOPMENT_USERS_PASSWORD",
default="",
)
- MAX_REQUEST_SIZE_MB: int = Field(
- alias="MAX_REQUEST_SIZE_MB", default=5 * 1024 * 1024
- )
- DATABASE_POOL_SIZE: int = Field(alias="DATABASE_POOL_SIZE", default=20)
- DATABASE_MAX_OVERFLOW: int = Field(alias="DATABASE_MAX_OVERFLOW", default=10)
- DATABASE_POOL_TIMEOUT: int = Field(alias="DATABASE_POOL_TIMEOUT", default=30)
- DATABASE_POOL_RECYCLE: int = Field(alias="DATABASE_POOL_RECYCLE", default=3600)
@property
def is_production(self) -> bool:
diff --git a/src/core/database/postgres/session.py b/src/core/database/postgres/session.py
index 374693a..ba5c3e5 100644
--- a/src/core/database/postgres/session.py
+++ b/src/core/database/postgres/session.py
@@ -4,7 +4,7 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from src.core.config.setting import get_settings
-from src.shared.database.unit_of_work import SQLAlchemyUnitOfWork
+from src.core.database.unit_of_work import SQLAlchemyUnitOfWork
from src.shared.unit_of_work import UnitOfWork
settings = get_settings()
diff --git a/src/shared/database/unit_of_work.py b/src/core/database/unit_of_work.py
similarity index 54%
rename from src/shared/database/unit_of_work.py
rename to src/core/database/unit_of_work.py
index a092ef5..7088e7d 100644
--- a/src/shared/database/unit_of_work.py
+++ b/src/core/database/unit_of_work.py
@@ -1,10 +1,14 @@
from types import TracebackType
-from typing import Self
+from typing import TYPE_CHECKING, Self
from sqlalchemy.ext.asyncio import AsyncSession
from src.shared.unit_of_work import UnitOfWork
+if TYPE_CHECKING:
+ from src.modules.todo.domain.repositories.todo_repository import TodoRepository
+ from src.modules.user.domain.repositories.user_repository import UserRepository
+
class SQLAlchemyUnitOfWork(UnitOfWork):
def __init__(self, session: AsyncSession):
@@ -31,3 +35,19 @@ async def commit(self) -> None:
async def rollback(self) -> None:
await self._session.rollback()
+
+ @property
+ def users(self) -> "UserRepository":
+ from src.modules.user.infrastructure.repositories.user_repository import (
+ SQLAlchemyUserRepository,
+ )
+
+ return SQLAlchemyUserRepository(self._session)
+
+ @property
+ def todos(self) -> "TodoRepository":
+ from src.modules.todo.infrastructure.repositories.todo_repository import (
+ SQLAlchemyTodoRepository,
+ )
+
+ return SQLAlchemyTodoRepository(self._session)
diff --git a/src/core/dependency/providers.py b/src/core/dependency/providers.py
new file mode 100644
index 0000000..6d271ca
--- /dev/null
+++ b/src/core/dependency/providers.py
@@ -0,0 +1,11 @@
+from fastapi import Depends
+
+from src.core.database.postgres.session import get_unit_of_work
+from src.modules.user.providers import UserModuleProvider
+from src.shared.unit_of_work import UnitOfWork
+
+
+def get_user_module_provider(
+ uow: UnitOfWork = Depends(get_unit_of_work),
+) -> UserModuleProvider:
+ return UserModuleProvider(user_repository=uow.users)
diff --git a/src/core/email/factory.py b/src/core/email/factory.py
new file mode 100644
index 0000000..12912a0
--- /dev/null
+++ b/src/core/email/factory.py
@@ -0,0 +1,35 @@
+from src.core.config import settings
+from src.core.email.providers.sendgrid_provider import SendGridProvider
+from src.core.email.providers.ses_provider import SESProvider
+from src.core.email.providers.smtp_provider import SMTPProvider
+from src.core.email.service import EmailService
+
+
+def create_email_service() -> EmailService:
+ """
+ Create email service with configured providers.
+ Providers are ordered by priority (first configured provider is used).
+ """
+ providers = []
+
+ # Add providers based on configuration
+ if settings.EMAIL_PROVIDER == "ses":
+ ses = SESProvider()
+ if ses.is_configured():
+ providers.append(ses)
+ elif settings.EMAIL_PROVIDER == "sendgrid":
+ sendgrid = SendGridProvider()
+ if sendgrid.is_configured():
+ providers.append(sendgrid)
+ elif settings.EMAIL_PROVIDER == "smtp":
+ smtp = SMTPProvider()
+ if smtp.is_configured():
+ providers.append(smtp)
+
+ # Fallback: always add SMTP as last resort if configured
+ if settings.SMTP_HOST and not any(isinstance(p, SMTPProvider) for p in providers):
+ smtp = SMTPProvider()
+ if smtp.is_configured():
+ providers.append(smtp)
+
+ return EmailService(providers)
diff --git a/src/core/email/providers/base.py b/src/core/email/providers/base.py
new file mode 100644
index 0000000..04c86f4
--- /dev/null
+++ b/src/core/email/providers/base.py
@@ -0,0 +1,17 @@
+from abc import ABC, abstractmethod
+
+from src.shared.email.base import EmailMessage
+
+
+class EmailProvider(ABC):
+ """Abstract base class for email providers"""
+
+ @abstractmethod
+ async def send(self, message: EmailMessage) -> bool:
+ """Send an email. Returns True if successful."""
+ pass
+
+ @abstractmethod
+ def is_configured(self) -> bool:
+ """Check if provider is properly configured"""
+ pass
diff --git a/src/core/email/providers/sendgrid_provider.py b/src/core/email/providers/sendgrid_provider.py
new file mode 100644
index 0000000..41dfbc3
--- /dev/null
+++ b/src/core/email/providers/sendgrid_provider.py
@@ -0,0 +1,31 @@
+import sendgrid
+from sendgrid.helpers.mail import Content, Email, Mail, To
+
+from src.core.config import settings
+from src.core.email.providers.base import EmailMessage, EmailProvider
+
+
+class SendGridProvider(EmailProvider):
+ def __init__(self):
+ self.client = sendgrid.SendGridAPIClient(api_key=settings.SENDGRID_API_KEY)
+
+ async def send(self, message: EmailMessage) -> bool:
+ try:
+ from_email = Email(message.from_email or settings.SENDGRID_FROM_EMAIL)
+ to_email = To(message.to)
+ subject = message.subject
+ content = Content("text/html", message.html_body)
+
+ mail = Mail(from_email, to_email, subject, content)
+
+ if message.text_body:
+ mail.add_content(Content("text/plain", message.text_body))
+
+ response = self.client.send(mail)
+ return 200 <= response.status_code < 300
+ except Exception as e:
+ print(f"SendGrid error: {e}")
+ return False
+
+ def is_configured(self) -> bool:
+ return bool(settings.SENDGRID_API_KEY)
diff --git a/src/core/email/providers/ses_provider.py b/src/core/email/providers/ses_provider.py
new file mode 100644
index 0000000..f7fa88f
--- /dev/null
+++ b/src/core/email/providers/ses_provider.py
@@ -0,0 +1,42 @@
+import boto3
+from botocore.exceptions import ClientError
+
+from src.core.config import settings
+from src.core.email.providers.base import EmailMessage, EmailProvider
+
+
+class SESProvider(EmailProvider):
+ def __init__(self):
+ self.client = boto3.client(
+ "ses",
+ region_name=settings.AWS_REGION,
+ aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
+ aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
+ )
+
+ async def send(self, message: EmailMessage) -> bool:
+ try:
+ source = (
+ f"{message.from_name} <{message.from_email}>"
+ if message.from_name
+ else message.from_email
+ )
+
+ response = self.client.send_email(
+ Source=source or settings.SES_FROM_EMAIL,
+ Destination={"ToAddresses": [message.to]},
+ Message={
+ "Subject": {"Data": message.subject},
+ "Body": {
+ "Html": {"Data": message.html_body},
+ "Text": {"Data": message.text_body or ""},
+ },
+ },
+ )
+ return response["ResponseMetadata"]["HTTPStatusCode"] == 200
+ except ClientError as e:
+ print(f"SES error: {e}")
+ return False
+
+ def is_configured(self) -> bool:
+ return bool(settings.AWS_ACCESS_KEY_ID and settings.AWS_SECRET_ACCESS_KEY)
diff --git a/src/core/email/providers/smtp_provider.py b/src/core/email/providers/smtp_provider.py
new file mode 100644
index 0000000..2d15353
--- /dev/null
+++ b/src/core/email/providers/smtp_provider.py
@@ -0,0 +1,43 @@
+import smtplib
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+
+from src.core.config import settings
+from src.core.email.providers.base import EmailMessage, EmailProvider
+
+
+class SMTPProvider(EmailProvider):
+ def __init__(self):
+ self.host = settings.SMTP_HOST
+ self.port = settings.SMTP_PORT
+ self.username = settings.SMTP_USERNAME
+ self.password = settings.SMTP_PASSWORD
+ self.use_tls = settings.SMTP_USE_TLS
+
+ async def send(self, message: EmailMessage) -> bool:
+ try:
+ msg = MIMEMultipart("alternative")
+ msg["Subject"] = message.subject
+ msg["From"] = (
+ f"{message.from_name or ''} <{message.from_email or settings.SMTP_FROM_EMAIL}>".strip()
+ )
+ msg["To"] = message.to
+
+ if message.text_body:
+ msg.attach(MIMEText(message.text_body, "plain"))
+ msg.attach(MIMEText(message.html_body, "html"))
+
+ with smtplib.SMTP(self.host, self.port) as server:
+ if self.use_tls:
+ server.starttls()
+ if self.username and self.password:
+ server.login(self.username, self.password)
+ server.send_message(msg)
+
+ return True
+ except Exception as e:
+ print(f"SMTP error: {e}")
+ return False
+
+ def is_configured(self) -> bool:
+ return bool(self.host and self.port)
diff --git a/src/core/email/service.py b/src/core/email/service.py
new file mode 100644
index 0000000..33d5d68
--- /dev/null
+++ b/src/core/email/service.py
@@ -0,0 +1,70 @@
+from typing import Optional
+
+from src.core.email.providers.base import EmailMessage, EmailProvider
+from src.core.email.template_renderer import EmailTemplateRenderer
+
+
+class EmailService:
+ """
+ High-level email service that handles provider selection and template rendering.
+ Supports fallback to alternative providers if primary fails.
+ """
+
+ def __init__(self, providers: list[EmailProvider]):
+ self.providers = providers
+ self.renderer = EmailTemplateRenderer()
+
+ async def send_email(
+ self,
+ to: str,
+ subject: str,
+ template_name: Optional[str] = None,
+ template_context: Optional[dict] = None,
+ html_body: Optional[str] = None,
+ text_body: Optional[str] = None,
+ from_email: Optional[str] = None,
+ from_name: Optional[str] = None,
+ ) -> bool:
+ """
+ Send an email with template rendering support.
+
+ Args:
+ to: Recipient email
+ subject: Email subject
+ template_name: HTML template file name (optional)
+ template_context: Variables for template (optional)
+ html_body: Direct HTML content (if not using template)
+ text_body: Plain text version (optional)
+ from_email: Sender email (optional, uses default)
+ from_name: Sender name (optional)
+
+ Returns:
+ True if sent successfully by any provider
+ """
+ # Render template if provided
+ if template_name and template_context:
+ html_body = self.renderer.render(template_name, template_context)
+
+ if not html_body:
+ raise ValueError("Either template_name or html_body must be provided")
+
+ message = EmailMessage(
+ to=to,
+ subject=subject,
+ html_body=html_body,
+ text_body=text_body,
+ from_email=from_email,
+ from_name=from_name,
+ )
+
+ # Try providers in order (fallback strategy)
+ for provider in self.providers:
+ if not provider.is_configured():
+ continue
+
+ success = await provider.send(message)
+ if success:
+ return True
+
+ # All providers failed
+ return False
diff --git a/src/core/email/template_renderer.py b/src/core/email/template_renderer.py
new file mode 100644
index 0000000..b9cbeee
--- /dev/null
+++ b/src/core/email/template_renderer.py
@@ -0,0 +1,29 @@
+from pathlib import Path
+from typing import Any
+
+from jinja2 import Environment, FileSystemLoader, select_autoescape
+
+
+class EmailTemplateRenderer:
+ """Renders HTML email templates with variables"""
+
+ def __init__(self, template_dir: str = "templates/emails"):
+ self.template_dir = Path(template_dir)
+ self.env = Environment(
+ loader=FileSystemLoader(self.template_dir),
+ autoescape=select_autoescape(["html", "xml"]),
+ )
+
+ def render(self, template_name: str, context: dict[str, Any]) -> str:
+ """
+ Render an HTML template with context variables.
+
+ Args:
+ template_name: Name of the template file (e.g., 'verification.html')
+ context: Dictionary of variables to inject into the template
+
+ Returns:
+ Rendered HTML string
+ """
+ template = self.env.get_template(template_name)
+ return template.render(**context)
diff --git a/src/core/events/bus.py b/src/core/events/bus.py
new file mode 100644
index 0000000..c8bfaa7
--- /dev/null
+++ b/src/core/events/bus.py
@@ -0,0 +1,47 @@
+from functools import lru_cache
+
+from src.shared.events.base import Event
+from src.shared.events.handler import EventHandler
+
+
+class EventBus:
+ """
+ General-purpose event bus for publishing and subscribing to events.
+ Supports multiple handlers per event type.
+ """
+
+ def __init__(self):
+ self._handlers: dict[str, list[EventHandler]] = {}
+
+ def subscribe(self, event_type: str, handler: EventHandler):
+ """Register a handler for a specific event type"""
+ if event_type not in self._handlers:
+ self._handlers[event_type] = []
+ self._handlers[event_type].append(handler)
+
+ def unsubscribe(self, event_type: str, handler: EventHandler):
+ """Remove a handler for a specific event type"""
+ if event_type in self._handlers:
+ self._handlers[event_type] = [
+ h for h in self._handlers[event_type] if h != handler
+ ]
+
+ async def publish(self, event: Event):
+ """Publish an event to all subscribed handlers"""
+ event_type = event.event_type
+ handlers = self._handlers.get(event_type, [])
+
+ for handler in handlers:
+ try:
+ await handler.handle(event)
+ except Exception as e:
+ # Log error but don't stop other handlers
+ print(
+ f"Error in handler {handler.__class__.__name__} for event {event_type}: {e}"
+ )
+ # In production, use proper logging and error tracking (Sentry, etc.)
+
+
+@lru_cache
+def get_event_bus() -> EventBus:
+ return EventBus()
diff --git a/src/core/exceptions/handler.py b/src/core/exceptions/handler.py
index b3f08ad..7ba2cf3 100644
--- a/src/core/exceptions/handler.py
+++ b/src/core/exceptions/handler.py
@@ -3,13 +3,11 @@
from fastapi.responses import JSONResponse
from src.core.schemas.response import ErrorDetail, ErrorResponse
-from src.modules.todo.domain.exceptions.todo_exception import (
+from src.modules.todo import (
TodoNotFoundError,
UnauthorizedTodoAccessError,
)
-
-# Import your custom domain exceptions here
-from src.modules.user.domain.exceptions.user_exception import UserAlreadyExistsError
+from src.modules.user import UserAlreadyExistsError
from src.shared.exceptions.credential_exception import (
InvalidCredentialsError,
InvalidRefreshTokenError,
diff --git a/src/core/lifespan.py b/src/core/lifespan.py
index b9ff7dc..6dd8af6 100644
--- a/src/core/lifespan.py
+++ b/src/core/lifespan.py
@@ -2,6 +2,7 @@
from fastapi import FastAPI
+from src.core.bootstrap.event import register_event_handlers
from src.core.database.postgres.session import engine
from src.core.dependency.rate_limit import close_rate_limiter, init_rate_limiter
@@ -9,6 +10,7 @@
@asynccontextmanager
async def lifespan(app: FastAPI):
await init_rate_limiter()
+ register_event_handlers()
yield
diff --git a/src/core/routers/api/v1.py b/src/core/routers/api/v1.py
index 1514227..0528fb4 100644
--- a/src/core/routers/api/v1.py
+++ b/src/core/routers/api/v1.py
@@ -1,9 +1,9 @@
from fastapi import APIRouter, FastAPI
-from src.modules.authorization.presenter.routers.permission_router import (
+from src.modules.authorization.presentation.routers.permission_router import (
router as permission_router,
)
-from src.modules.authorization.presenter.routers.role_router import (
+from src.modules.authorization.presentation.routers.role_router import (
router as role_router,
)
from src.modules.todo.presentation.routers.todo_router import router as todo_router
diff --git a/src/core/security/two_factor_auth.py b/src/core/security/two_factor_auth.py
new file mode 100644
index 0000000..84d40c5
--- /dev/null
+++ b/src/core/security/two_factor_auth.py
@@ -0,0 +1,324 @@
+"""Two-Factor Authentication service supporting TOTP and email-based 2FA."""
+
+from __future__ import annotations
+
+import secrets
+from typing import Literal
+from uuid import UUID
+
+import pyotp
+
+from src.core.config.setting import get_settings
+from src.core.email.service import EmailService
+from src.modules.user.domain.entities.user import User
+from src.modules.user.domain.repositories.user_repository import UserRepository
+from src.shared.unit_of_work import UnitOfWork
+
+
+class TwoFactorAuthService:
+ """Service for managing two-factor authentication.
+
+ Supports:
+ - TOTP (Time-based One-Time Password) for authenticator apps like Google Authenticator, Authy, etc.
+ - Email-based 2FA codes
+ """
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ email_service: EmailService | None = None,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+ self._email_service = email_service
+ self._settings = get_settings()
+
+ async def setup_totp(self, user_id: UUID) -> dict[str, str]:
+ """Set up TOTP for a user.
+
+ Returns:
+ dict with 'secret', 'uri', and 'qr_code_data' keys
+ """
+ async with self._unit_of_work:
+ user = await self._user_repository.get_by_id_with_relations(user_id)
+ if not user or not user.security:
+ raise ValueError("User not found")
+
+ # Generate a new secret
+ secret = pyotp.random_base32()
+
+ # Create TOTP URI for QR code generation
+ issuer = self._settings.JWT_ISSUER or "TodoApp"
+ uri = pyotp.totp.TOTP(secret).provisioning_uri(
+ name=user.email, issuer_name=issuer
+ )
+
+ # Store the secret temporarily (not enabled yet)
+ user.security.two_factor_secret = secret
+ user.security.two_factor_enabled = False
+ await self._user_repository.save_security(user.security)
+ await self._unit_of_work.commit()
+
+ return {
+ "secret": secret,
+ "uri": uri,
+ "qr_code_data": f"otpauth://totp/{issuer}:{user.email}?secret={secret}&issuer={issuer}",
+ }
+
+ async def verify_totp_setup(self, user_id: UUID, code: str) -> dict[str, list[str]]:
+ """Verify TOTP setup and enable 2FA.
+
+ Args:
+ user_id: The user's ID
+ code: The TOTP code from the authenticator app
+
+ Returns:
+ dict with 'backup_codes' key containing recovery codes
+ """
+ async with self._unit_of_work:
+ user = await self._user_repository.get_by_id_with_relations(user_id)
+ if not user or not user.security:
+ raise ValueError("User not found")
+
+ if not user.security.two_factor_secret:
+ raise ValueError("TOTP not set up. Call setup_totp first.")
+
+ # Verify the code
+ totp = pyotp.TOTP(user.security.two_factor_secret)
+ if not totp.verify(code, valid_window=1):
+ raise ValueError("Invalid TOTP code")
+
+ # Generate backup codes
+ backup_codes = [secrets.token_hex(4) for _ in range(10)]
+
+ # Enable 2FA and store backup codes
+ user.security.two_factor_enabled = True
+ user.security.two_factor_backup_codes = ",".join(backup_codes)
+ await self._user_repository.save_security(user.security)
+ await self._unit_of_work.commit()
+
+ return {"backup_codes": backup_codes}
+
+ async def disable_totp(self, user_id: UUID, code: str) -> bool:
+ """Disable TOTP 2FA for a user.
+
+ Args:
+ user_id: The user's ID
+ code: Current TOTP code or backup code for verification
+
+ Returns:
+ True if successfully disabled
+ """
+ async with self._unit_of_work:
+ user = await self._user_repository.get_by_id_with_relations(user_id)
+ if not user or not user.security:
+ raise ValueError("User not found")
+
+ if not user.security.two_factor_enabled:
+ raise ValueError("2FA is not enabled")
+
+ # Verify code
+ verified = False
+
+ # Check if it's a backup code
+ if user.security.two_factor_backup_codes:
+ backup_codes = user.security.two_factor_backup_codes.split(",")
+ if code in backup_codes:
+ backup_codes.remove(code)
+ user.security.two_factor_backup_codes = ",".join(backup_codes)
+ verified = True
+
+ # Check if it's a TOTP code
+ if not verified and user.security.two_factor_secret:
+ totp = pyotp.TOTP(user.security.two_factor_secret)
+ if totp.verify(code, valid_window=1):
+ verified = True
+
+ if not verified:
+ raise ValueError("Invalid verification code")
+
+ # Disable 2FA
+ user.security.two_factor_enabled = False
+ user.security.two_factor_secret = None
+ user.security.two_factor_backup_codes = None
+ await self._user_repository.save_security(user.security)
+ await self._unit_of_work.commit()
+
+ return True
+
+ async def send_email_2fa_code(self, user_id: UUID) -> bool:
+ """Send a 2FA code via email.
+
+ Args:
+ user_id: The user's ID
+
+ Returns:
+ True if email was sent successfully
+ """
+ if self._email_service is None:
+ raise ValueError("Email service not configured")
+
+ async with self._unit_of_work:
+ user = await self._user_repository.get_by_id_with_relations(user_id)
+ if not user:
+ raise ValueError("User not found")
+
+ # Generate a 6-digit code
+ code = secrets.token_hex(3)[:6]
+
+ # Store the code temporarily in security settings (with expiry info)
+ # In production, you'd want to store this in Redis with TTL
+ if not user.security:
+ raise ValueError("User security not found")
+
+ # Store code with timestamp (format: "code:timestamp")
+ import time
+
+ user.security.two_factor_secret = f"email_code:{code}:{int(time.time())}"
+ await self._user_repository.save_security(user.security)
+ await self._unit_of_work.commit()
+
+ # Send email
+ html_body = f"""
+
+
+ Your Verification Code
+ Your verification code is: {code}
+ This code will expire in 10 minutes.
+ If you didn't request this code, please ignore this email.
+
+
+ """
+
+ await self._email_service.send_email(
+ to=user.email,
+ subject="Your Verification Code",
+ html_body=html_body,
+ )
+
+ return True
+
+ async def verify_email_2fa_code(self, user_id: UUID, code: str) -> bool:
+ """Verify an email-based 2FA code.
+
+ Args:
+ user_id: The user's ID
+ code: The code received via email
+
+ Returns:
+ True if code is valid
+ """
+ async with self._unit_of_work:
+ user = await self._user_repository.get_by_id_with_relations(user_id)
+ if not user or not user.security:
+ raise ValueError("User not found")
+
+ stored = user.security.two_factor_secret
+ if not stored or not stored.startswith("email_code:"):
+ raise ValueError("No pending email verification code")
+
+ parts = stored.split(":")
+ if len(parts) != 3:
+ raise ValueError("Invalid code format")
+
+ stored_code = parts[1]
+ timestamp = int(parts[2])
+
+ import time
+
+ current_time = int(time.time())
+
+ # Code expires after 10 minutes
+ if current_time - timestamp > 600:
+ raise ValueError("Code has expired")
+
+ if stored_code != code:
+ raise ValueError("Invalid code")
+
+ # Clear the stored code
+ user.security.two_factor_secret = None
+ await self._user_repository.save_security(user.security)
+ await self._unit_of_work.commit()
+
+ return True
+
+ async def verify_2fa_code(
+ self, user: User, code: str, method: Literal["totp", "email", "backup"] = "totp"
+ ) -> bool:
+ """Verify a 2FA code using the specified method.
+
+ Args:
+ user: The user entity
+ code: The verification code
+ method: The verification method ('totp', 'email', or 'backup')
+
+ Returns:
+ True if verification successful
+ """
+ if not user.security:
+ raise ValueError("User security not found")
+
+ if method == "totp":
+ if (
+ not user.security.two_factor_secret
+ or not user.security.two_factor_enabled
+ ):
+ raise ValueError("TOTP 2FA is not enabled")
+
+ totp = pyotp.TOTP(user.security.two_factor_secret)
+ return bool(totp.verify(code, valid_window=1))
+
+ elif method == "backup":
+ if not user.security.two_factor_backup_codes:
+ raise ValueError("No backup codes available")
+
+ backup_codes = user.security.two_factor_backup_codes.split(",")
+ if code in backup_codes:
+ # Remove used backup code
+ backup_codes.remove(code)
+ user.security.two_factor_backup_codes = ",".join(backup_codes)
+ async with self._unit_of_work:
+ await self._user_repository.save_security(user.security)
+ await self._unit_of_work.commit()
+ return True
+ return False
+
+ elif method == "email":
+ return await self.verify_email_2fa_code(user.id, code)
+
+ return False
+
+ async def regenerate_backup_codes(
+ self, user_id: UUID, verify_code: str
+ ) -> dict[str, list[str]]:
+ """Regenerate backup codes for a user.
+
+ Args:
+ user_id: The user's ID
+ verify_code: Current TOTP code for verification
+
+ Returns:
+ dict with 'backup_codes' key containing new recovery codes
+ """
+ async with self._unit_of_work:
+ user = await self._user_repository.get_by_id_with_relations(user_id)
+ if not user or not user.security:
+ raise ValueError("User not found")
+
+ if not user.security.two_factor_enabled:
+ raise ValueError("2FA must be enabled to regenerate backup codes")
+
+ # Verify TOTP code
+ if user.security.two_factor_secret:
+ totp = pyotp.TOTP(user.security.two_factor_secret)
+ if not totp.verify(verify_code, valid_window=1):
+ raise ValueError("Invalid TOTP code")
+
+ # Generate new backup codes
+ backup_codes = [secrets.token_hex(4) for _ in range(10)]
+ user.security.two_factor_backup_codes = ",".join(backup_codes)
+ await self._user_repository.save_security(user.security)
+ await self._unit_of_work.commit()
+
+ return {"backup_codes": backup_codes}
diff --git a/src/core/seed/authorization.py b/src/core/seed/authorization.py
index d87948b..5f3ca13 100644
--- a/src/core/seed/authorization.py
+++ b/src/core/seed/authorization.py
@@ -1,14 +1,12 @@
from dataclasses import dataclass
from typing import Protocol
-from src.core.authorization.permissions import (
+from src.modules.authorization import AuthorizationResource, Permission, Role
+from src.modules.authorization.domain.permissions import (
+ DEFAULT_POLICIES,
DEFAULT_RESOURCES,
DEFAULT_ROLES,
- DEFAULT_POLICIES,
)
-from src.modules.authorization.domain.entities.permission import Permission
-from src.modules.authorization.domain.entities.resource import AuthorizationResource
-from src.modules.authorization.domain.entities.role import Role
class AuthorizationSeedRepository(Protocol):
@@ -86,7 +84,9 @@ async def seed_authorization(
if name in existing_roles:
continue
- role = await repository.create_role(Role.create(name=name, description=description))
+ role = await repository.create_role(
+ Role.create(name=name, description=description)
+ )
existing_roles[role.name] = role
roles_created += 1
@@ -140,24 +140,16 @@ async def seed_authorization(
def _default_roles() -> dict[str, str]:
- return {
- role.name: role.description
- for role in DEFAULT_ROLES
- }
+ return {role.name: role.description for role in DEFAULT_ROLES}
def _default_permission_keys() -> list[str]:
- return [
- permission_key
- for _, _, permission_key in _permission_policies()
- ]
+ return [permission_key for _, _, permission_key in _permission_policies()]
def _permission_policies() -> list[tuple[str, str, str]]:
return [
- policy
- for policy in DEFAULT_POLICIES
- if policy[0] == "p" and policy[2] != "*"
+ policy for policy in DEFAULT_POLICIES if policy[0] == "p" and policy[2] != "*"
]
diff --git a/src/core/seed/runner.py b/src/core/seed/runner.py
index dfd4555..2bed0ca 100644
--- a/src/core/seed/runner.py
+++ b/src/core/seed/runner.py
@@ -2,16 +2,16 @@
from sqlalchemy.ext.asyncio import AsyncSession
-from src.core.authorization.infrastructure.repositories.casbin_policy_repository import (
- SQLAlchemyCasbinPolicyRepository,
-)
-from src.core.authorization.infrastructure.services.casbin_authorization_service import (
- CasbinAuthorizationService,
-)
from src.core.config.setting import get_settings
from src.core.database.postgres.session import AsyncSessionLocal
from src.core.seed.authorization import AuthorizationSeedResult, seed_authorization
from src.core.seed.user import SeedUserConfig, UserSeedResult, seed_user
+from src.modules.authorization.infrastructure.repositories.casbin_policy_repository import (
+ SQLAlchemyCasbinPolicyRepository,
+)
+from src.modules.authorization.infrastructure.services.casbin_authorization_service import (
+ CasbinAuthorizationService,
+)
from src.modules.user.infrastructure.repositories.user_repository import (
SQLAlchemyUserRepository,
)
diff --git a/src/core/seed/user.py b/src/core/seed/user.py
index 0f10f32..6bcbe2c 100644
--- a/src/core/seed/user.py
+++ b/src/core/seed/user.py
@@ -1,14 +1,14 @@
from dataclasses import dataclass
from typing import Protocol
-from src.core.authorization.permissions import (
+from src.core.security.password import PasswordSerrvice
+from src.modules.authorization.domain.permissions import (
ADMIN_ROLE,
DEFAULT_USER_ROLE,
MANAGER_ROLE,
VIEWER_ROLE,
)
-from src.core.security.password import PasswordSerrvice
-from src.modules.user.domain.entities.user import User
+from src.modules.user.domain.entities.user import User, UserProfile
class SeedUserRepository(Protocol):
@@ -18,6 +18,9 @@ async def get_by_email(self, email: str) -> User | None:
async def save(self, user: User) -> User:
raise NotImplementedError
+ async def save_profile(self, profile: UserProfile) -> UserProfile:
+ raise NotImplementedError
+
class SeedAuthorizationService(Protocol):
async def assign_role(self, subject: str, role: str) -> None:
@@ -50,9 +53,8 @@ def has_admin_credentials(self) -> bool:
@property
def should_seed_development_users(self) -> bool:
- return (
- self.app_env.lower() == "development"
- and bool(self.development_users_password.strip())
+ return self.app_env.lower() == "development" and bool(
+ self.development_users_password.strip()
)
@@ -152,11 +154,16 @@ async def _seed_one_user(
user = User.create(
email=email,
- password=PasswordSerrvice.hash(password),
+ password_hash=PasswordSerrvice.hash(password),
username=username,
- fullname=fullname,
)
saved_user = await user_repository.save(user)
+ await user_repository.save_profile(
+ UserProfile(
+ user_id=saved_user.id,
+ display_name=fullname,
+ )
+ )
await authorization_service.assign_role(str(saved_user.id), role)
return UserSeedResult(users_created=1, roles_assigned=1)
diff --git a/src/modules/authorization/__init__.py b/src/modules/authorization/__init__.py
new file mode 100644
index 0000000..ca6058f
--- /dev/null
+++ b/src/modules/authorization/__init__.py
@@ -0,0 +1,9 @@
+from src.modules.authorization.domain.entities.permission import Permission
+from src.modules.authorization.domain.entities.resource import AuthorizationResource
+from src.modules.authorization.domain.entities.role import Role
+
+__all__ = [
+ "AuthorizationResource",
+ "Permission",
+ "Role",
+]
diff --git a/src/modules/authorization/application/__init__.py b/src/modules/authorization/application/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/create_permission/__init__.py b/src/modules/authorization/application/create_permission/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/create_permission/command.py b/src/modules/authorization/application/create_permission/command.py
new file mode 100644
index 0000000..9d2a79b
--- /dev/null
+++ b/src/modules/authorization/application/create_permission/command.py
@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+
+class CreatePermissionCommand(BaseModel):
+ resource: str
+ action: str
+ description: str | None = None
diff --git a/src/modules/authorization/application/create_permission/handler.py b/src/modules/authorization/application/create_permission/handler.py
new file mode 100644
index 0000000..569972c
--- /dev/null
+++ b/src/modules/authorization/application/create_permission/handler.py
@@ -0,0 +1,31 @@
+from src.modules.authorization.application.create_permission.command import (
+ CreatePermissionCommand,
+)
+from src.modules.authorization.domain.entities.permission import Permission
+from src.modules.authorization.domain.permissions import permission_key
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+from src.shared.unit_of_work import UnitOfWork
+
+
+class CreatePermissionHandler:
+ def __init__(
+ self,
+ policy_repo: CasbinPolicyRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._policy_repo = policy_repo
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: CreatePermissionCommand) -> Permission:
+ permission = Permission.create(
+ key=permission_key(command.resource, command.action),
+ resource=command.resource,
+ action=command.action,
+ description=command.description,
+ )
+ async with self._unit_of_work:
+ created = await self._policy_repo.create_permission(permission)
+ await self._unit_of_work.commit()
+ return created
diff --git a/src/modules/authorization/application/create_role/__init__.py b/src/modules/authorization/application/create_role/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/create_role/command.py b/src/modules/authorization/application/create_role/command.py
new file mode 100644
index 0000000..e91f655
--- /dev/null
+++ b/src/modules/authorization/application/create_role/command.py
@@ -0,0 +1,6 @@
+from pydantic import BaseModel
+
+
+class CreateRoleCommand(BaseModel):
+ name: str
+ description: str | None = None
diff --git a/src/modules/authorization/application/create_role/handler.py b/src/modules/authorization/application/create_role/handler.py
new file mode 100644
index 0000000..5b29225
--- /dev/null
+++ b/src/modules/authorization/application/create_role/handler.py
@@ -0,0 +1,23 @@
+from src.modules.authorization.application.create_role.command import CreateRoleCommand
+from src.modules.authorization.domain.entities.role import Role
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+from src.shared.unit_of_work import UnitOfWork
+
+
+class CreateRoleHandler:
+ def __init__(
+ self,
+ policy_repo: CasbinPolicyRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._policy_repo = policy_repo
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: CreateRoleCommand) -> Role:
+ role = Role.create(name=command.name, description=command.description)
+ async with self._unit_of_work:
+ created = await self._policy_repo.create_role(role)
+ await self._unit_of_work.commit()
+ return created
diff --git a/src/modules/authorization/application/delete_permission/__init__.py b/src/modules/authorization/application/delete_permission/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/delete_permission/command.py b/src/modules/authorization/application/delete_permission/command.py
new file mode 100644
index 0000000..cf3306e
--- /dev/null
+++ b/src/modules/authorization/application/delete_permission/command.py
@@ -0,0 +1,7 @@
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class DeletePermissionCommand(BaseModel):
+ permission_id: UUID
diff --git a/src/modules/authorization/application/delete_permission/handler.py b/src/modules/authorization/application/delete_permission/handler.py
new file mode 100644
index 0000000..00f0c6f
--- /dev/null
+++ b/src/modules/authorization/application/delete_permission/handler.py
@@ -0,0 +1,22 @@
+from src.modules.authorization.application.delete_permission.command import (
+ DeletePermissionCommand,
+)
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+from src.shared.unit_of_work import UnitOfWork
+
+
+class DeletePermissionHandler:
+ def __init__(
+ self,
+ policy_repo: CasbinPolicyRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._policy_repo = policy_repo
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: DeletePermissionCommand) -> None:
+ async with self._unit_of_work:
+ await self._policy_repo.delete_permission(command.permission_id)
+ await self._unit_of_work.commit()
diff --git a/src/modules/authorization/application/delete_role/__init__.py b/src/modules/authorization/application/delete_role/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/delete_role/command.py b/src/modules/authorization/application/delete_role/command.py
new file mode 100644
index 0000000..26a2906
--- /dev/null
+++ b/src/modules/authorization/application/delete_role/command.py
@@ -0,0 +1,7 @@
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class DeleteRoleCommand(BaseModel):
+ role_id: UUID
diff --git a/src/modules/authorization/application/delete_role/handler.py b/src/modules/authorization/application/delete_role/handler.py
new file mode 100644
index 0000000..5f8c61b
--- /dev/null
+++ b/src/modules/authorization/application/delete_role/handler.py
@@ -0,0 +1,20 @@
+from src.modules.authorization.application.delete_role.command import DeleteRoleCommand
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+from src.shared.unit_of_work import UnitOfWork
+
+
+class DeleteRoleHandler:
+ def __init__(
+ self,
+ policy_repo: CasbinPolicyRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._policy_repo = policy_repo
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: DeleteRoleCommand) -> None:
+ async with self._unit_of_work:
+ await self._policy_repo.delete_role(command.role_id)
+ await self._unit_of_work.commit()
diff --git a/src/modules/authorization/application/get_permission/__init__.py b/src/modules/authorization/application/get_permission/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/get_permission/handler.py b/src/modules/authorization/application/get_permission/handler.py
new file mode 100644
index 0000000..a10944c
--- /dev/null
+++ b/src/modules/authorization/application/get_permission/handler.py
@@ -0,0 +1,13 @@
+from src.modules.authorization.application.get_permission.query import GetPermissionQuery
+from src.modules.authorization.domain.entities.permission import Permission
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+
+
+class GetPermissionQueryHandler:
+ def __init__(self, policy_repo: CasbinPolicyRepository):
+ self._policy_repo = policy_repo
+
+ async def execute(self, query: GetPermissionQuery) -> Permission | None:
+ return await self._policy_repo.get_permission(query.permission_id)
diff --git a/src/modules/authorization/application/get_permission/query.py b/src/modules/authorization/application/get_permission/query.py
new file mode 100644
index 0000000..b1f2a6b
--- /dev/null
+++ b/src/modules/authorization/application/get_permission/query.py
@@ -0,0 +1,7 @@
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class GetPermissionQuery(BaseModel):
+ permission_id: UUID
diff --git a/src/modules/authorization/application/get_role/__init__.py b/src/modules/authorization/application/get_role/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/get_role/handler.py b/src/modules/authorization/application/get_role/handler.py
new file mode 100644
index 0000000..8a36d58
--- /dev/null
+++ b/src/modules/authorization/application/get_role/handler.py
@@ -0,0 +1,13 @@
+from src.modules.authorization.application.get_role.query import GetRoleQuery
+from src.modules.authorization.domain.entities.role import Role
+from src.modules.authorization.infrastructure.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+
+
+class GetRoleQueryHandler:
+ def __init__(self, policy_repo: CasbinPolicyRepository):
+ self._policy_repo = policy_repo
+
+ async def execute(self, query: GetRoleQuery) -> Role | None:
+ return await self._policy_repo.get_role(query.role_id)
diff --git a/src/modules/authorization/application/get_role/query.py b/src/modules/authorization/application/get_role/query.py
new file mode 100644
index 0000000..6111fe5
--- /dev/null
+++ b/src/modules/authorization/application/get_role/query.py
@@ -0,0 +1,7 @@
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class GetRoleQuery(BaseModel):
+ role_id: UUID
diff --git a/src/modules/authorization/application/list_permissions/__init__.py b/src/modules/authorization/application/list_permissions/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/list_permissions/query.py b/src/modules/authorization/application/list_permissions/query.py
new file mode 100644
index 0000000..7892285
--- /dev/null
+++ b/src/modules/authorization/application/list_permissions/query.py
@@ -0,0 +1,5 @@
+from pydantic import BaseModel
+
+
+class ListPermissionsQuery(BaseModel):
+ pass
diff --git a/src/modules/authorization/application/list_roles/__init__.py b/src/modules/authorization/application/list_roles/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/list_roles/handler.py b/src/modules/authorization/application/list_roles/handler.py
new file mode 100644
index 0000000..5fe5825
--- /dev/null
+++ b/src/modules/authorization/application/list_roles/handler.py
@@ -0,0 +1,13 @@
+from src.modules.authorization.application.list_roles.query import ListRolesQuery
+from src.modules.authorization.domain.entities.role import Role
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+
+
+class ListRolesQueryHandler:
+ def __init__(self, policy_repo: CasbinPolicyRepository):
+ self._policy_repo = policy_repo
+
+ async def execute(self, query: ListRolesQuery) -> list[Role]:
+ return await self._policy_repo.list_roles()
diff --git a/src/modules/authorization/application/list_roles/query.py b/src/modules/authorization/application/list_roles/query.py
new file mode 100644
index 0000000..234a8f1
--- /dev/null
+++ b/src/modules/authorization/application/list_roles/query.py
@@ -0,0 +1,5 @@
+from pydantic import BaseModel
+
+
+class ListRolesQuery(BaseModel):
+ pass
diff --git a/src/modules/authorization/application/update_permission/__init__.py b/src/modules/authorization/application/update_permission/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/update_permission/command.py b/src/modules/authorization/application/update_permission/command.py
new file mode 100644
index 0000000..d18b2a0
--- /dev/null
+++ b/src/modules/authorization/application/update_permission/command.py
@@ -0,0 +1,10 @@
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class UpdatePermissionCommand(BaseModel):
+ permission_id: UUID
+ resource: str | None = None
+ action: str | None = None
+ description: str | None = None
diff --git a/src/modules/authorization/application/update_permission/handler.py b/src/modules/authorization/application/update_permission/handler.py
new file mode 100644
index 0000000..229f523
--- /dev/null
+++ b/src/modules/authorization/application/update_permission/handler.py
@@ -0,0 +1,44 @@
+from src.modules.authorization.application.update_permission.command import (
+ UpdatePermissionCommand,
+)
+from src.modules.authorization.domain.entities.permission import Permission
+from src.modules.authorization.domain.permissions import permission_key
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+from src.shared.unit_of_work import UnitOfWork
+
+
+class UpdatePermissionHandler:
+ def __init__(
+ self,
+ policy_repo: CasbinPolicyRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._policy_repo = policy_repo
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: UpdatePermissionCommand) -> Permission | None:
+ existing = await self._policy_repo.get_permission(command.permission_id)
+ if existing is None:
+ return None
+
+ resource = (
+ command.resource if command.resource is not None else existing.resource
+ )
+ action = command.action if command.action is not None else existing.action
+ permission = Permission(
+ id=command.permission_id,
+ key=permission_key(resource, action),
+ resource=resource,
+ action=action,
+ description=(
+ command.description
+ if command.description is not None
+ else existing.description
+ ),
+ )
+ async with self._unit_of_work:
+ updated = await self._policy_repo.update_permission(permission)
+ await self._unit_of_work.commit()
+ return updated
diff --git a/src/modules/authorization/application/update_role/__init__.py b/src/modules/authorization/application/update_role/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/application/update_role/command.py b/src/modules/authorization/application/update_role/command.py
new file mode 100644
index 0000000..0d1459f
--- /dev/null
+++ b/src/modules/authorization/application/update_role/command.py
@@ -0,0 +1,9 @@
+from uuid import UUID
+
+from pydantic import BaseModel
+
+
+class UpdateRoleCommand(BaseModel):
+ role_id: UUID
+ name: str | None = None
+ description: str | None = None
diff --git a/src/modules/authorization/application/update_role/handler.py b/src/modules/authorization/application/update_role/handler.py
new file mode 100644
index 0000000..a232ba7
--- /dev/null
+++ b/src/modules/authorization/application/update_role/handler.py
@@ -0,0 +1,35 @@
+from src.modules.authorization.application.update_role.command import UpdateRoleCommand
+from src.modules.authorization.domain.entities.role import Role
+from src.modules.authorization.domain.repositories.casbin_policy_repository import (
+ CasbinPolicyRepository,
+)
+from src.shared.unit_of_work import UnitOfWork
+
+
+class UpdateRoleHandler:
+ def __init__(
+ self,
+ policy_repo: CasbinPolicyRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._policy_repo = policy_repo
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: UpdateRoleCommand) -> Role | None:
+ existing = await self._policy_repo.get_role(command.role_id)
+ if existing is None:
+ return None
+
+ role = Role(
+ id=command.role_id,
+ name=command.name if command.name is not None else existing.name,
+ description=(
+ command.description
+ if command.description is not None
+ else existing.description
+ ),
+ )
+ async with self._unit_of_work:
+ updated = await self._policy_repo.update_role(role)
+ await self._unit_of_work.commit()
+ return updated
diff --git a/src/modules/authorization/domain/__init__.py b/src/modules/authorization/domain/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/domain/entities/__init__.py b/src/modules/authorization/domain/entities/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/authorization/permissions.py b/src/modules/authorization/domain/permissions.py
similarity index 100%
rename from src/core/authorization/permissions.py
rename to src/modules/authorization/domain/permissions.py
diff --git a/src/modules/authorization/domain/services/__init__.py b/src/modules/authorization/domain/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/authorization/domain/service.py b/src/modules/authorization/domain/services/authorization_service.py
similarity index 92%
rename from src/core/authorization/domain/service.py
rename to src/modules/authorization/domain/services/authorization_service.py
index 3d0537c..9f5486f 100644
--- a/src/core/authorization/domain/service.py
+++ b/src/modules/authorization/domain/services/authorization_service.py
@@ -2,9 +2,8 @@
from datetime import datetime
from uuid import UUID
-from src.core.utils.cursor import CursorDirection
-from src.modules.authorization.domain.entities.permission import Permission
-from src.modules.authorization.domain.entities.role import Role
+from src.modules.authorization import Permission, Role
+from src.shared.utils.cursor import CursorDirection
class AuthorizationService(ABC):
diff --git a/src/modules/authorization/infrastructure/models/__init__.py b/src/modules/authorization/infrastructure/models/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/authorization/infrastructure/models/casbin_rule_model.py b/src/modules/authorization/infrastructure/models/casbin_rule_model.py
similarity index 100%
rename from src/core/authorization/infrastructure/models/casbin_rule_model.py
rename to src/modules/authorization/infrastructure/models/casbin_rule_model.py
diff --git a/src/modules/authorization/infrastructure/models/permission_model.py b/src/modules/authorization/infrastructure/models/permission_model.py
new file mode 100644
index 0000000..50ed645
--- /dev/null
+++ b/src/modules/authorization/infrastructure/models/permission_model.py
@@ -0,0 +1,43 @@
+from uuid import UUID
+
+from sqlalchemy import ForeignKey, Index, String, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class PermissionModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """Permission definition for RBAC system.
+
+ Permissions represent specific actions on resources.
+ Linked to authorization_resources for resource management.
+ """
+
+ __tablename__ = "permissions"
+ __table_args__ = (
+ UniqueConstraint("resource", "action", name="uq_permissions_resource_action"),
+ Index("ix_permissions_key", "key", unique=True),
+ Index("ix_permissions_resource", "resource"),
+ Index("ix_permissions_action", "action"),
+ Index("ix_permissions_resource_id", "resource_id"),
+ )
+
+ key: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
+ resource_id: Mapped[UUID] = mapped_column(
+ ForeignKey("authorization_resources.id"),
+ nullable=False,
+ )
+ resource: Mapped[str] = mapped_column(String(100), nullable=False)
+ action: Mapped[str] = mapped_column(String(100), nullable=False)
+ description: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ # Relationships
+ roles: Mapped[list["RolePermissionModel"]] = relationship( # type: ignore[name-defined]
+ back_populates="permission",
+ cascade="all, delete-orphan",
+ )
+ authorization_resource: Mapped["AuthorizationResourceModel"] = relationship( # type: ignore[name-defined]
+ back_populates="permissions",
+ foreign_keys=[resource_id],
+ )
diff --git a/src/modules/authorization/infrastructure/models/resource_model.py b/src/modules/authorization/infrastructure/models/resource_model.py
new file mode 100644
index 0000000..7a9b5cb
--- /dev/null
+++ b/src/modules/authorization/infrastructure/models/resource_model.py
@@ -0,0 +1,26 @@
+from sqlalchemy import Index, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class AuthorizationResourceModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """Authorization resource definition for grouping permissions.
+
+ Resources represent domain entities that permissions act upon.
+ Examples: users, todos, documents, reports.
+ """
+
+ __tablename__ = "authorization_resources"
+ __table_args__ = (Index("ix_authorization_resources_key", "key", unique=True),)
+
+ key: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
+ name: Mapped[str] = mapped_column(String(150), nullable=False)
+ description: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ # Relationships
+ permissions: Mapped[list["PermissionModel"]] = relationship( # type: ignore[name-defined]
+ back_populates="authorization_resource",
+ cascade="all, delete-orphan",
+ )
diff --git a/src/modules/authorization/infrastructure/models/role_model.py b/src/modules/authorization/infrastructure/models/role_model.py
new file mode 100644
index 0000000..96401f0
--- /dev/null
+++ b/src/modules/authorization/infrastructure/models/role_model.py
@@ -0,0 +1,34 @@
+from sqlalchemy import Index, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.authorization.infrastructure.models.role_permission_model import (
+ RolePermissionModel,
+)
+from src.modules.authorization.infrastructure.models.user_has_role_model import (
+ UserHasRoleModel,
+)
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class RoleModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """Role definition for RBAC system.
+
+ Roles group permissions and can be assigned to users.
+ """
+
+ __tablename__ = "roles"
+ __table_args__ = (Index("ix_roles_name", "name", unique=True),)
+
+ name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
+ description: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ # Relationships
+ permissions: Mapped[list["RolePermissionModel"]] = relationship(
+ back_populates="role",
+ cascade="all, delete-orphan",
+ )
+ user_assignments: Mapped[list["UserHasRoleModel"]] = relationship(
+ back_populates="role",
+ cascade="all, delete-orphan",
+ )
diff --git a/src/modules/authorization/infrastructure/models/role_permission_model.py b/src/modules/authorization/infrastructure/models/role_permission_model.py
new file mode 100644
index 0000000..4c10c64
--- /dev/null
+++ b/src/modules/authorization/infrastructure/models/role_permission_model.py
@@ -0,0 +1,40 @@
+from uuid import UUID
+
+from sqlalchemy import ForeignKey, Index, UniqueConstraint
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.shared.database.model import Base
+
+
+class RolePermissionModel(Base):
+ """Junction table for role-to-permission assignments.
+
+ Many-to-many relationship between roles and permissions.
+ """
+
+ __tablename__ = "role_permissions"
+ __table_args__ = (
+ UniqueConstraint(
+ "role_id",
+ "permission_id",
+ name="uq_role_permissions_role_id_permission_id",
+ ),
+ Index("ix_role_permissions_role_id", "role_id"),
+ Index("ix_role_permissions_permission_id", "permission_id"),
+ )
+
+ role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), nullable=False)
+ permission_id: Mapped[UUID] = mapped_column(
+ ForeignKey("permissions.id"),
+ nullable=False,
+ )
+
+ # Relationships
+ role: Mapped["RoleModel"] = relationship( # type: ignore[name-defined]
+ back_populates="permissions",
+ foreign_keys=[role_id],
+ )
+ permission: Mapped["PermissionModel"] = relationship( # type: ignore[name-defined]
+ back_populates="roles",
+ foreign_keys=[permission_id],
+ )
diff --git a/src/modules/authorization/infrastructure/models/user_has_role_model.py b/src/modules/authorization/infrastructure/models/user_has_role_model.py
new file mode 100644
index 0000000..b587c7a
--- /dev/null
+++ b/src/modules/authorization/infrastructure/models/user_has_role_model.py
@@ -0,0 +1,42 @@
+from uuid import UUID
+
+from sqlalchemy import ForeignKey, Index, UniqueConstraint
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.user.infrastructure.models.user_model import UserModel
+from src.shared.database.model import Base
+
+
+class UserHasRoleModel(Base):
+ """Junction table for user-to-role assignments.
+
+ Many-to-many relationship between users and roles.
+ Supports RBAC by linking users to their assigned roles.
+ """
+
+ __tablename__ = "user_has_roles"
+ __table_args__ = (
+ UniqueConstraint(
+ "user_id", "role_id", name="uq_user_has_roles_user_id_role_id"
+ ),
+ Index("ix_user_has_roles_user_id", "user_id"),
+ Index("ix_user_has_roles_role_id", "role_id"),
+ )
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ nullable=False,
+ )
+ role_id: Mapped[UUID] = mapped_column(ForeignKey("roles.id"), nullable=False)
+
+ # Relationships
+ user: Mapped["UserModel"] = relationship( # type: ignore[name-defined]
+ back_populates="role_assignments",
+ foreign_keys=[user_id],
+ )
+ role: Mapped["RoleModel"] = relationship( # type: ignore[name-defined]
+ back_populates="user_assignments",
+ foreign_keys=[role_id],
+ )
diff --git a/src/modules/authorization/infrastructure/repositories/__init__.py b/src/modules/authorization/infrastructure/repositories/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/authorization/infrastructure/repositories/casbin_policy_repository.py b/src/modules/authorization/infrastructure/repositories/casbin_policy_repository.py
similarity index 96%
rename from src/core/authorization/infrastructure/repositories/casbin_policy_repository.py
rename to src/modules/authorization/infrastructure/repositories/casbin_policy_repository.py
index 7d0696e..3cb5c09 100644
--- a/src/core/authorization/infrastructure/repositories/casbin_policy_repository.py
+++ b/src/modules/authorization/infrastructure/repositories/casbin_policy_repository.py
@@ -4,26 +4,24 @@
from sqlalchemy import and_, delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
-from src.core.utils.cursor import CursorDirection
-from src.core.authorization.infrastructure.models.casbin_rule_model import (
+from src.modules.authorization import AuthorizationResource, Permission, Role
+from src.modules.authorization.infrastructure.models.casbin_rule_model import (
CasbinRuleModel,
)
-from src.core.authorization.infrastructure.models.permission_model import (
+from src.modules.authorization.infrastructure.models.permission_model import (
PermissionModel,
)
-from src.core.authorization.infrastructure.models.resource_model import (
+from src.modules.authorization.infrastructure.models.resource_model import (
AuthorizationResourceModel,
)
-from src.core.authorization.infrastructure.models.role_model import RoleModel
-from src.core.authorization.infrastructure.models.role_permission_model import (
+from src.modules.authorization.infrastructure.models.role_model import RoleModel
+from src.modules.authorization.infrastructure.models.role_permission_model import (
RolePermissionModel,
)
-from src.core.authorization.infrastructure.models.user_has_role_model import (
+from src.modules.authorization.infrastructure.models.user_has_role_model import (
UserHasRoleModel,
)
-from src.modules.authorization.domain.entities.permission import Permission
-from src.modules.authorization.domain.entities.resource import AuthorizationResource
-from src.modules.authorization.domain.entities.role import Role
+from src.shared.utils.cursor import CursorDirection
class SQLAlchemyCasbinPolicyRepository:
diff --git a/src/modules/authorization/infrastructure/services/__init__.py b/src/modules/authorization/infrastructure/services/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/authorization/infrastructure/services/casbin_authorization_service.py b/src/modules/authorization/infrastructure/services/casbin_authorization_service.py
similarity index 93%
rename from src/core/authorization/infrastructure/services/casbin_authorization_service.py
rename to src/modules/authorization/infrastructure/services/casbin_authorization_service.py
index 727cd9e..ae90710 100644
--- a/src/core/authorization/infrastructure/services/casbin_authorization_service.py
+++ b/src/modules/authorization/infrastructure/services/casbin_authorization_service.py
@@ -1,14 +1,16 @@
from datetime import datetime
from uuid import UUID
-from src.core.authorization.domain.service import AuthorizationService
-from src.core.authorization.infrastructure.repositories.casbin_policy_repository import (
- SQLAlchemyCasbinPolicyRepository,
-)
-from src.core.authorization.permissions import permission_key
-from src.core.utils.cursor import CursorDirection
from src.modules.authorization.domain.entities.permission import Permission
from src.modules.authorization.domain.entities.role import Role
+from src.modules.authorization.domain.permissions import permission_key
+from src.modules.authorization.domain.services.authorization_service import (
+ AuthorizationService,
+)
+from src.modules.authorization.infrastructure.repositories.casbin_policy_repository import (
+ SQLAlchemyCasbinPolicyRepository,
+)
+from src.shared.utils.cursor import CursorDirection
CASBIN_MODEL_TEXT = """
[request_definition]
diff --git a/src/modules/authorization/presentation/__init__.py b/src/modules/authorization/presentation/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/core/authorization/dependencies.py b/src/modules/authorization/presentation/dependency.py
similarity index 79%
rename from src/core/authorization/dependencies.py
rename to src/modules/authorization/presentation/dependency.py
index 39b43f5..c5f91e0 100644
--- a/src/core/authorization/dependencies.py
+++ b/src/modules/authorization/presentation/dependency.py
@@ -3,15 +3,17 @@
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
-from src.core.authorization.domain.service import AuthorizationService
-from src.core.authorization.infrastructure.repositories.casbin_policy_repository import (
+from src.core.database.postgres.session import get_db
+from src.core.dependency.auth import get_current_user
+from src.modules.authorization.domain.services.authorization_service import (
+ AuthorizationService,
+)
+from src.modules.authorization.infrastructure.repositories.casbin_policy_repository import (
SQLAlchemyCasbinPolicyRepository,
)
-from src.core.authorization.infrastructure.services.casbin_authorization_service import (
+from src.modules.authorization.infrastructure.services.casbin_authorization_service import (
CasbinAuthorizationService,
)
-from src.core.database.postgres.session import get_db
-from src.core.dependency.auth import get_current_user
def get_authorization_service(
diff --git a/src/modules/authorization/presentation/routers/__init__.py b/src/modules/authorization/presentation/routers/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/presenter/routers/permission_router.py b/src/modules/authorization/presentation/routers/permission_router.py
similarity index 85%
rename from src/modules/authorization/presenter/routers/permission_router.py
rename to src/modules/authorization/presentation/routers/permission_router.py
index b68f0db..04511c7 100644
--- a/src/modules/authorization/presenter/routers/permission_router.py
+++ b/src/modules/authorization/presentation/routers/permission_router.py
@@ -4,11 +4,14 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
-from src.core.authorization.dependencies import require_permission
-from src.core.authorization.infrastructure.services.casbin_authorization_service import (
- CasbinAuthorizationService,
+from src.core.database.postgres.session import get_unit_of_work
+from src.core.schemas.response import (
+ CursorMeta,
+ CursorPaginatedResponse,
+ SuccessResponse,
)
-from src.core.authorization.permissions import (
+from src.modules.authorization.domain.entities.permission import Permission
+from src.modules.authorization.domain.permissions import (
CREATE_ACTION,
DELETE_ACTION,
PERMISSION_RESOURCE,
@@ -16,23 +19,20 @@
UPDATE_ACTION,
permission_key,
)
-from src.core.database.postgres.session import get_unit_of_work
-from src.core.schemas.response import (
- CursorMeta,
- CursorPaginatedResponse,
- SuccessResponse,
+from src.modules.authorization.infrastructure.services.casbin_authorization_service import (
+ CasbinAuthorizationService,
)
-from src.core.utils.cursor import CursorDirection, decode_cursor, encode_cursor
-from src.modules.authorization.domain.entities.permission import Permission
-from src.modules.authorization.presenter.dependency import (
- get_casbin_authorization_service,
+from src.modules.authorization.presentation.dependency import (
+ get_authorization_service,
+ require_permission,
)
-from src.modules.authorization.presenter.schema.request import (
+from src.modules.authorization.presentation.schema.request import (
CreatePermissionRequest,
UpdatePermissionRequest,
)
-from src.modules.authorization.presenter.schema.response import PermissionResponse
+from src.modules.authorization.presentation.schema.response import PermissionResponse
from src.shared.unit_of_work import UnitOfWork
+from src.shared.utils.cursor import CursorDirection, decode_cursor, encode_cursor
router = APIRouter(prefix="/permissions", tags=["Permission"])
@@ -45,7 +45,7 @@
)
async def create_permission(
request: CreatePermissionRequest,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
permission = Permission.create(
@@ -75,7 +75,7 @@ async def list_permissions(
None, description="Cursor for pagination (from previous response)"
),
limit: int = Query(10, ge=1, le=100, description="Number of items per page"),
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
):
cursor_created_at = None
cursor_id = None
@@ -112,10 +112,7 @@ async def list_permissions(
return CursorPaginatedResponse(
success=True,
message="fetch permission success",
- data=[
- _permission_response(permission)
- for permission in permissions
- ],
+ data=[_permission_response(permission) for permission in permissions],
meta=CursorMeta(
next_cursor=next_cursor,
prev_cursor=prev_cursor,
@@ -133,7 +130,7 @@ async def list_permissions(
)
async def get_permission(
permission_id: UUID,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
):
permission = await service.get_permission(permission_id)
if permission is None:
@@ -153,7 +150,7 @@ async def get_permission(
async def update_permission(
permission_id: UUID,
request: UpdatePermissionRequest,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
existing = await service.get_permission(permission_id)
@@ -191,7 +188,7 @@ async def update_permission(
)
async def delete_permission(
permission_id: UUID,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
async with unit_of_work:
diff --git a/src/modules/authorization/presenter/routers/role_router.py b/src/modules/authorization/presentation/routers/role_router.py
similarity index 84%
rename from src/modules/authorization/presenter/routers/role_router.py
rename to src/modules/authorization/presentation/routers/role_router.py
index af2bdf3..928e4b9 100644
--- a/src/modules/authorization/presenter/routers/role_router.py
+++ b/src/modules/authorization/presentation/routers/role_router.py
@@ -4,34 +4,34 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
-from src.core.authorization.dependencies import require_permission
-from src.core.authorization.infrastructure.services.casbin_authorization_service import (
- CasbinAuthorizationService,
+from src.core.database.postgres.session import get_unit_of_work
+from src.core.schemas.response import (
+ CursorMeta,
+ CursorPaginatedResponse,
+ SuccessResponse,
)
-from src.core.authorization.permissions import (
+from src.modules.authorization.domain.entities.role import Role
+from src.modules.authorization.domain.permissions import (
CREATE_ACTION,
DELETE_ACTION,
READ_ACTION,
ROLE_RESOURCE,
UPDATE_ACTION,
)
-from src.core.database.postgres.session import get_unit_of_work
-from src.core.schemas.response import (
- CursorMeta,
- CursorPaginatedResponse,
- SuccessResponse,
+from src.modules.authorization.infrastructure.services.casbin_authorization_service import (
+ CasbinAuthorizationService,
)
-from src.core.utils.cursor import CursorDirection, decode_cursor, encode_cursor
-from src.modules.authorization.domain.entities.role import Role
-from src.modules.authorization.presenter.dependency import (
- get_casbin_authorization_service,
+from src.modules.authorization.presentation.dependency import (
+ get_authorization_service,
+ require_permission,
)
-from src.modules.authorization.presenter.schema.request import (
+from src.modules.authorization.presentation.schema.request import (
CreateRoleRequest,
UpdateRoleRequest,
)
-from src.modules.authorization.presenter.schema.response import RoleResponse
+from src.modules.authorization.presentation.schema.response import RoleResponse
from src.shared.unit_of_work import UnitOfWork
+from src.shared.utils.cursor import CursorDirection, decode_cursor, encode_cursor
router = APIRouter(prefix="/roles", tags=["Role"])
@@ -44,7 +44,7 @@
)
async def create_role(
request: CreateRoleRequest,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
role = Role.create(name=request.name, description=request.description)
@@ -66,7 +66,7 @@ async def list_roles(
None, description="Cursor for pagination (from previous response)"
),
limit: int = Query(10, ge=1, le=100, description="Number of items per page"),
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
):
cursor_created_at = None
cursor_id = None
@@ -121,7 +121,7 @@ async def list_roles(
)
async def get_role(
role_id: UUID,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
):
role = await service.get_role(role_id)
if role is None:
@@ -139,7 +139,7 @@ async def get_role(
async def update_role(
role_id: UUID,
request: UpdateRoleRequest,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
existing = await service.get_role(role_id)
@@ -170,7 +170,7 @@ async def update_role(
)
async def delete_role(
role_id: UUID,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
async with unit_of_work:
@@ -186,7 +186,7 @@ async def delete_role(
async def assign_permission_to_role(
role_id: UUID,
permission_id: UUID,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
async with unit_of_work:
@@ -202,7 +202,7 @@ async def assign_permission_to_role(
async def remove_permission_from_role(
role_id: UUID,
permission_id: UUID,
- service: CasbinAuthorizationService = Depends(get_casbin_authorization_service),
+ service: CasbinAuthorizationService = Depends(get_authorization_service),
unit_of_work: UnitOfWork = Depends(get_unit_of_work),
):
async with unit_of_work:
diff --git a/src/modules/authorization/presentation/schema/__init__.py b/src/modules/authorization/presentation/schema/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/modules/authorization/presenter/schema/request.py b/src/modules/authorization/presentation/schema/request.py
similarity index 100%
rename from src/modules/authorization/presenter/schema/request.py
rename to src/modules/authorization/presentation/schema/request.py
diff --git a/src/modules/authorization/presenter/schema/response.py b/src/modules/authorization/presentation/schema/response.py
similarity index 100%
rename from src/modules/authorization/presenter/schema/response.py
rename to src/modules/authorization/presentation/schema/response.py
diff --git a/src/modules/authorization/presenter/dependency.py b/src/modules/authorization/presenter/dependency.py
deleted file mode 100644
index d05665f..0000000
--- a/src/modules/authorization/presenter/dependency.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from fastapi import Depends
-from sqlalchemy.ext.asyncio import AsyncSession
-
-from src.core.authorization.infrastructure.repositories.casbin_policy_repository import (
- SQLAlchemyCasbinPolicyRepository,
-)
-from src.core.authorization.infrastructure.services.casbin_authorization_service import (
- CasbinAuthorizationService,
-)
-from src.core.database.postgres.session import get_db
-
-
-def get_casbin_policy_repository(
- db: AsyncSession = Depends(get_db),
-) -> SQLAlchemyCasbinPolicyRepository:
- return SQLAlchemyCasbinPolicyRepository(db=db)
-
-
-def get_casbin_authorization_service(
- repository: SQLAlchemyCasbinPolicyRepository = Depends(
- get_casbin_policy_repository
- ),
-) -> CasbinAuthorizationService:
- return CasbinAuthorizationService(repository)
diff --git a/src/modules/todo/__init__.py b/src/modules/todo/__init__.py
new file mode 100644
index 0000000..7252498
--- /dev/null
+++ b/src/modules/todo/__init__.py
@@ -0,0 +1,13 @@
+from src.modules.todo.domain.entities.todo import Todo
+from src.modules.todo.domain.exceptions.todo_exception import (
+ TodoNotFoundError,
+ UnauthorizedTodoAccessError,
+)
+from src.modules.todo.domain.repositories.todo_repository import TodoRepository
+
+__all__ = [
+ "Todo",
+ "TodoNotFoundError",
+ "TodoRepository",
+ "UnauthorizedTodoAccessError",
+]
diff --git a/src/modules/todo/application/detail_todo/handler.py b/src/modules/todo/application/detail_todo/handler.py
new file mode 100644
index 0000000..7f4efa5
--- /dev/null
+++ b/src/modules/todo/application/detail_todo/handler.py
@@ -0,0 +1,41 @@
+from uuid import UUID
+
+from src.modules.todo import (
+ TodoNotFoundError,
+ TodoRepository,
+ UnauthorizedTodoAccessError,
+)
+from src.modules.todo.presentation.schemas.response import TodoWithOwnerResponse
+from src.modules.user import UserNotFoundError
+from src.modules.user.providers import UserModuleProvider
+
+
+class GetTodoDetailWithOwnerHandler:
+ def __init__(
+ self,
+ todo_repo: TodoRepository,
+ user_provider: UserModuleProvider,
+ ):
+ self._todo_repo = todo_repo
+ self._user_provider = user_provider
+
+ async def execute(self, todo_id: UUID, user_id: UUID) -> TodoWithOwnerResponse:
+ todo = await self._todo_repo.get_by_id(todo_id)
+ if not todo:
+ raise TodoNotFoundError("Todo not found")
+ if todo.user_id != user_id:
+ raise UnauthorizedTodoAccessError(
+ "You do not have permission to view this todo"
+ )
+
+ owner = await self._user_provider.get_user_profile(todo.user_id)
+ if not owner:
+ raise UserNotFoundError("Todo owner not found")
+
+ return TodoWithOwnerResponse(
+ id=str(todo.id),
+ title=todo.title,
+ description=todo.description,
+ is_completed=todo.is_completed,
+ owner=owner,
+ )
diff --git a/src/modules/todo/application/list_todo/handler.py b/src/modules/todo/application/list_todo/handler.py
index bec39f1..ca02dc0 100644
--- a/src/modules/todo/application/list_todo/handler.py
+++ b/src/modules/todo/application/list_todo/handler.py
@@ -1,11 +1,11 @@
from datetime import datetime
from uuid import UUID
-from src.core.utils.cursor import CursorDirection
from src.modules.todo.application.list_todo.query import GetTodosQuery
from src.modules.todo.application.list_todo.validation import validate_get_todos_query
from src.modules.todo.domain.entities.todo import Todo
from src.modules.todo.domain.repositories.todo_repository import TodoRepository
+from src.shared.utils.cursor import CursorDirection
class GetTodosQueryHandler:
diff --git a/src/modules/todo/domain/repositories/todo_repository.py b/src/modules/todo/domain/repositories/todo_repository.py
index d4e6301..9495bae 100644
--- a/src/modules/todo/domain/repositories/todo_repository.py
+++ b/src/modules/todo/domain/repositories/todo_repository.py
@@ -2,8 +2,8 @@
from datetime import datetime
from uuid import UUID
-from src.core.utils.cursor import CursorDirection
from src.modules.todo.domain.entities.todo import Todo
+from src.shared.utils.cursor import CursorDirection
class TodoRepository(ABC):
diff --git a/src/modules/todo/infrastructure/repositories/todo_repository.py b/src/modules/todo/infrastructure/repositories/todo_repository.py
index 592774c..4978076 100644
--- a/src/modules/todo/infrastructure/repositories/todo_repository.py
+++ b/src/modules/todo/infrastructure/repositories/todo_repository.py
@@ -4,10 +4,10 @@
from sqlalchemy import and_, delete, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
-from src.core.utils.cursor import CursorDirection
from src.modules.todo.domain.entities.todo import Todo
from src.modules.todo.domain.repositories.todo_repository import TodoRepository
from src.modules.todo.infrastructure.models.todo_model import TodoModel
+from src.shared.utils.cursor import CursorDirection
class SQLAlchemyTodoRepository(TodoRepository):
@@ -129,3 +129,12 @@ async def save(self, todo: Todo) -> Todo:
async def delete(self, todo_id: UUID) -> None:
await self.db.execute(delete(TodoModel).where(TodoModel.id == todo_id))
await self.db.flush()
+
+ def _to_entity(self, model: TodoModel) -> Todo:
+ return Todo(
+ id=str(model.id),
+ description=model.description,
+ is_completed=model.is_completed,
+ title=model.title,
+ user_id=model.user_id,
+ )
diff --git a/src/modules/todo/presentation/dependency.py b/src/modules/todo/presentation/dependency.py
index a6ca8a4..6f05a5a 100644
--- a/src/modules/todo/presentation/dependency.py
+++ b/src/modules/todo/presentation/dependency.py
@@ -2,8 +2,12 @@
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.database.postgres.session import get_db, get_unit_of_work
+from src.core.dependency.providers import get_user_module_provider
from src.modules.todo.application.create_todo.handler import CreateTodoHandler
from src.modules.todo.application.delete_todo.handler import DeleteTodoHandler
+from src.modules.todo.application.detail_todo.handler import (
+ GetTodoDetailWithOwnerHandler,
+)
from src.modules.todo.application.list_todo.handler import (
GetTodosCursorQuery,
)
@@ -12,6 +16,7 @@
from src.modules.todo.infrastructure.repositories.todo_repository import (
SQLAlchemyTodoRepository,
)
+from src.modules.user.providers import UserModuleProvider
from src.shared.unit_of_work import UnitOfWork
@@ -40,6 +45,13 @@ def get_delete_todo_handler(
return DeleteTodoHandler(repo, unit_of_work)
+def get_todo_detail_with_owner_handler(
+ todo_repo: TodoRepository = Depends(get_todo_repository),
+ user_provider: UserModuleProvider = Depends(get_user_module_provider),
+) -> GetTodoDetailWithOwnerHandler:
+ return GetTodoDetailWithOwnerHandler(todo_repo, user_provider=user_provider)
+
+
def get_todos_query_handler(
repo: TodoRepository = Depends(get_todo_repository),
) -> GetTodosCursorQuery:
diff --git a/src/modules/todo/presentation/routers/todo_router.py b/src/modules/todo/presentation/routers/todo_router.py
index ea18414..2770f71 100644
--- a/src/modules/todo/presentation/routers/todo_router.py
+++ b/src/modules/todo/presentation/routers/todo_router.py
@@ -3,23 +3,25 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
-from src.core.authorization.dependencies import require_permission
-from src.core.authorization.permissions import (
+from src.core.schemas.response import (
+ CursorMeta,
+ CursorPaginatedResponse,
+ SuccessResponse,
+)
+from src.modules.authorization.domain.permissions import (
CREATE_ACTION,
DELETE_ACTION,
READ_ACTION,
TODO_RESOURCE,
UPDATE_ACTION,
)
-from src.core.schemas.response import (
- CursorMeta,
- CursorPaginatedResponse,
- SuccessResponse,
-)
-from src.core.utils.cursor import CursorDirection, decode_cursor, encode_cursor
+from src.modules.authorization.presentation.dependency import require_permission
from src.modules.todo.application.create_todo.command import CreateTodoCommand
from src.modules.todo.application.create_todo.handler import CreateTodoHandler
from src.modules.todo.application.delete_todo.handler import DeleteTodoHandler
+from src.modules.todo.application.detail_todo.handler import (
+ GetTodoDetailWithOwnerHandler,
+)
from src.modules.todo.application.list_todo.handler import (
GetTodosCursorQuery,
)
@@ -33,10 +35,16 @@
from src.modules.todo.presentation.dependency import (
get_create_todo_handler,
get_delete_todo_handler,
+ get_todo_detail_with_owner_handler,
get_todos_query_handler,
get_update_todo_handler,
)
-from src.modules.todo.presentation.schemas.response import TodoResponse
+from src.modules.todo.presentation.schemas.response import (
+ TodoResponse,
+ TodoWithOwnerResponse,
+)
+from src.modules.user import UserNotFoundError
+from src.shared.utils.cursor import CursorDirection, decode_cursor, encode_cursor
router = APIRouter(prefix="/todos", tags=["Todos"])
@@ -90,9 +98,7 @@ async def get_todos(
TodoResponse(
id=str(t.id),
title=t.title,
- description=t.description,
is_completed=t.is_completed,
- created_at=t.created_at.isoformat(),
)
for t in todos
]
@@ -129,6 +135,29 @@ async def get_todos(
)
+@router.get("/{todo_id}", response_model=SuccessResponse[TodoWithOwnerResponse])
+async def get_todo_detail(
+ todo_id: UUID,
+ current_user: dict = Depends(require_permission(TODO_RESOURCE, READ_ACTION)),
+ handler: GetTodoDetailWithOwnerHandler = Depends(
+ get_todo_detail_with_owner_handler
+ ),
+):
+ try:
+ todo = await handler.execute(todo_id=todo_id, user_id=current_user.get("id"))
+ return SuccessResponse(
+ message="Todo retrieved successfully",
+ success=True,
+ data=todo,
+ )
+ except TodoNotFoundError:
+ raise HTTPException(status_code=404, detail="Todo not found")
+ except UnauthorizedTodoAccessError:
+ raise HTTPException(status_code=403, detail="Forbidden")
+ except UserNotFoundError:
+ raise HTTPException(status_code=404, detail="Todo owner not found")
+
+
@router.patch("/{todo_id}", response_model=SuccessResponse[TodoResponse])
async def update_todo(
todo_id: UUID,
diff --git a/src/modules/todo/presentation/schemas/response.py b/src/modules/todo/presentation/schemas/response.py
index 854faf4..2b47472 100644
--- a/src/modules/todo/presentation/schemas/response.py
+++ b/src/modules/todo/presentation/schemas/response.py
@@ -1,7 +1,17 @@
from pydantic import BaseModel
+from src.modules.user import UserProfile
+
class TodoResponse(BaseModel):
id: str
title: str
- is_compled: bool
+ is_completed: bool
+
+
+class TodoWithOwnerResponse(BaseModel):
+ id: str
+ title: str
+ description: str | None = None
+ is_completed: bool
+ owner: UserProfile
diff --git a/src/modules/user/__init__.py b/src/modules/user/__init__.py
new file mode 100644
index 0000000..6017ace
--- /dev/null
+++ b/src/modules/user/__init__.py
@@ -0,0 +1,11 @@
+from src.modules.user.domain.exceptions.user_exception import (
+ UserAlreadyExistsError,
+ UserNotFoundError,
+)
+from src.modules.user.providers import UserProfile
+
+__all__ = [
+ "UserProfile",
+ "UserAlreadyExistsError",
+ "UserNotFoundError",
+]
diff --git a/src/modules/user/application/login_user/command.py b/src/modules/user/application/auth/login_user/command.py
similarity index 100%
rename from src/modules/user/application/login_user/command.py
rename to src/modules/user/application/auth/login_user/command.py
diff --git a/src/modules/user/application/login_user/handler.py b/src/modules/user/application/auth/login_user/handler.py
similarity index 70%
rename from src/modules/user/application/login_user/handler.py
rename to src/modules/user/application/auth/login_user/handler.py
index 0df548e..6038569 100644
--- a/src/modules/user/application/login_user/handler.py
+++ b/src/modules/user/application/auth/login_user/handler.py
@@ -1,13 +1,16 @@
import hashlib
from datetime import datetime, timedelta, timezone
+from typing import Literal
from src.core.config.setting import get_settings
from src.core.security.account_lockout import AccountLockoutService
from src.core.security.audit import AuditEvent, AuditService
from src.core.security.jwt import JWTService
from src.core.security.password import PasswordSerrvice
-from src.modules.user.application.login_user.command import LoginUserCommand
-from src.modules.user.application.login_user.validation import validate_login_user_command
+from src.modules.user.application.auth.login_user.command import LoginUserCommand
+from src.modules.user.application.auth.login_user.validation import (
+ validate_login_user_command,
+)
from src.modules.user.domain.entities.refresh_token import RefreshToken
from src.modules.user.domain.repositories.refresh_token_repository import (
RefreshTokenRepository,
@@ -34,7 +37,12 @@ def __init__(
self._account_lockout_service = account_lockout_service
self._audit_service = audit_service
- async def execute(self, command: LoginUserCommand) -> dict[str, str]:
+ async def execute(
+ self,
+ command: LoginUserCommand,
+ two_factor_code: str | None = None,
+ two_factor_method: Literal["totp", "email", "backup"] | None = None,
+ ) -> dict[str, str]:
validate_login_user_command(command)
if self._account_lockout_service is not None:
@@ -54,11 +62,43 @@ async def execute(self, command: LoginUserCommand) -> dict[str, str]:
raise InvalidCredentialsError("Incorrect email or password")
if not user or not PasswordSerrvice.verify_password(
- command.password, user.password
+ command.password, user.password_hash
):
await self._record_failed_login(command.username)
raise InvalidCredentialsError("Incorrect email or password")
+ # Check if 2FA is enabled and requires verification
+ if user.security and user.security.two_factor_enabled:
+ if not two_factor_code:
+ # Return a temporary token indicating 2FA is required
+ temp_token = JWTService.create_access_token(
+ data={"sub": str(user.id), "2fa_required": True},
+ expires_delta=timedelta(minutes=5),
+ )
+ return {
+ "access_token": temp_token,
+ "refresh_token": "",
+ "2fa_required": True,
+ }
+
+ # Verify 2FA code
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ two_factor_service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ )
+
+ verified = await two_factor_service.verify_2fa_code(
+ user=user,
+ code=two_factor_code,
+ method=two_factor_method or "totp",
+ )
+
+ if not verified:
+ await self._record_failed_login(command.username)
+ raise InvalidCredentialsError("Invalid 2FA code")
+
access_token = JWTService.create_access_token(data={"sub": str(user.id)})
refresh_token_str = JWTService.create_refresh_token(data={"sub": str(user.id)})
diff --git a/src/modules/user/application/login_user/validation.py b/src/modules/user/application/auth/login_user/validation.py
similarity index 74%
rename from src/modules/user/application/login_user/validation.py
rename to src/modules/user/application/auth/login_user/validation.py
index f80cad2..74cad89 100644
--- a/src/modules/user/application/login_user/validation.py
+++ b/src/modules/user/application/auth/login_user/validation.py
@@ -1,4 +1,4 @@
-from src.modules.user.application.login_user.command import LoginUserCommand
+from src.modules.user.application.auth.login_user.command import LoginUserCommand
def validate_login_user_command(command: LoginUserCommand) -> None:
diff --git a/src/modules/user/application/logout_user/command.py b/src/modules/user/application/auth/logout_user/command.py
similarity index 100%
rename from src/modules/user/application/logout_user/command.py
rename to src/modules/user/application/auth/logout_user/command.py
diff --git a/src/modules/user/application/logout_user/handler.py b/src/modules/user/application/auth/logout_user/handler.py
similarity index 87%
rename from src/modules/user/application/logout_user/handler.py
rename to src/modules/user/application/auth/logout_user/handler.py
index 9e1287e..efabdca 100644
--- a/src/modules/user/application/logout_user/handler.py
+++ b/src/modules/user/application/auth/logout_user/handler.py
@@ -1,11 +1,11 @@
-from src.modules.user.application.logout_user.command import LogoutUserCommand
-from src.modules.user.application.logout_user.validation import (
+from src.core.security.token_revocation import TokenRevocationService
+from src.modules.user.application.auth.logout_user.command import LogoutUserCommand
+from src.modules.user.application.auth.logout_user.validation import (
validate_logout_user_command,
)
from src.modules.user.domain.repositories.refresh_token_repository import (
RefreshTokenRepository,
)
-from src.core.security.token_revocation import TokenRevocationService
from src.shared.unit_of_work import UnitOfWork
diff --git a/src/modules/user/application/logout_user/validation.py b/src/modules/user/application/auth/logout_user/validation.py
similarity index 79%
rename from src/modules/user/application/logout_user/validation.py
rename to src/modules/user/application/auth/logout_user/validation.py
index 44ed3b6..328c847 100644
--- a/src/modules/user/application/logout_user/validation.py
+++ b/src/modules/user/application/auth/logout_user/validation.py
@@ -1,6 +1,6 @@
from uuid import UUID
-from src.modules.user.application.logout_user.command import LogoutUserCommand
+from src.modules.user.application.auth.logout_user.command import LogoutUserCommand
def validate_logout_user_command(command: LogoutUserCommand) -> None:
diff --git a/src/modules/user/application/refresh_token/command.py b/src/modules/user/application/auth/refresh_token/command.py
similarity index 100%
rename from src/modules/user/application/refresh_token/command.py
rename to src/modules/user/application/auth/refresh_token/command.py
diff --git a/src/modules/user/application/refresh_token/handler.py b/src/modules/user/application/auth/refresh_token/handler.py
similarity index 94%
rename from src/modules/user/application/refresh_token/handler.py
rename to src/modules/user/application/auth/refresh_token/handler.py
index b4f6eec..d7465fa 100644
--- a/src/modules/user/application/refresh_token/handler.py
+++ b/src/modules/user/application/auth/refresh_token/handler.py
@@ -3,8 +3,8 @@
from src.core.config.setting import get_settings
from src.core.security.jwt import JWTService
-from src.modules.user.application.refresh_token.command import RefreshTokenCommand
-from src.modules.user.application.refresh_token.validation import (
+from src.modules.user.application.auth.refresh_token.command import RefreshTokenCommand
+from src.modules.user.application.auth.refresh_token.validation import (
validate_refresh_token_command,
)
from src.modules.user.domain.entities.refresh_token import RefreshToken
diff --git a/src/modules/user/application/refresh_token/validation.py b/src/modules/user/application/auth/refresh_token/validation.py
similarity index 65%
rename from src/modules/user/application/refresh_token/validation.py
rename to src/modules/user/application/auth/refresh_token/validation.py
index 54cfe8b..ea6e688 100644
--- a/src/modules/user/application/refresh_token/validation.py
+++ b/src/modules/user/application/auth/refresh_token/validation.py
@@ -1,4 +1,4 @@
-from src.modules.user.application.refresh_token.command import RefreshTokenCommand
+from src.modules.user.application.auth.refresh_token.command import RefreshTokenCommand
def validate_refresh_token_command(command: RefreshTokenCommand) -> None:
diff --git a/src/modules/user/application/register_user/command.py b/src/modules/user/application/auth/register_user/command.py
similarity index 100%
rename from src/modules/user/application/register_user/command.py
rename to src/modules/user/application/auth/register_user/command.py
diff --git a/src/modules/user/application/register_user/handler.py b/src/modules/user/application/auth/register_user/handler.py
similarity index 58%
rename from src/modules/user/application/register_user/handler.py
rename to src/modules/user/application/auth/register_user/handler.py
index 4444079..c00bb50 100644
--- a/src/modules/user/application/register_user/handler.py
+++ b/src/modules/user/application/auth/register_user/handler.py
@@ -1,13 +1,19 @@
+import secrets
+
+from src.core.events.bus import get_event_bus
from src.core.security.password import PasswordSerrvice
-from src.modules.user.application.register_user.command import RegisterUserCommand
-from src.modules.user.application.register_user.validation import (
+from src.modules.authorization.domain.permissions import DEFAULT_USER_ROLE
+from src.modules.authorization.domain.services.authorization_service import (
+ AuthorizationService,
+)
+from src.modules.user.application.auth.register_user.command import RegisterUserCommand
+from src.modules.user.application.auth.register_user.validation import (
validate_register_user_command,
)
from src.modules.user.domain.entities.user import User
+from src.modules.user.domain.events.emails.event import UserRegisteredEvent
from src.modules.user.domain.exceptions.user_exception import UserAlreadyExistsError
from src.modules.user.domain.repositories.user_repository import UserRepository
-from src.core.authorization.domain.service import AuthorizationService
-from src.core.authorization.permissions import DEFAULT_USER_ROLE
from src.shared.unit_of_work import UnitOfWork
@@ -31,14 +37,28 @@ async def execute(self, command: RegisterUserCommand) -> User:
hashed_password = PasswordSerrvice.hash(command.password)
user = User.create(
- command.email,
- password=hashed_password,
+ email=command.email,
+ password_hash=hashed_password,
)
async with self._unit_of_work:
+ # Save user (this also creates default profile, settings, security)
saved_user = await self._user_repository.save(user=user)
+
+ # Assign default role
await self._authorization_service.assign_role(
subject=str(saved_user.id),
role=DEFAULT_USER_ROLE,
)
await self._unit_of_work.commit()
+
+ verification_token = secrets.token_urlsafe(32)
+
+ await get_event_bus().publish(
+ UserRegisteredEvent(
+ user_id=str(saved_user.id),
+ email=saved_user.email,
+ verification_token=verification_token,
+ ),
+ )
+
return saved_user
diff --git a/src/modules/user/application/register_user/validation.py b/src/modules/user/application/auth/register_user/validation.py
similarity index 75%
rename from src/modules/user/application/register_user/validation.py
rename to src/modules/user/application/auth/register_user/validation.py
index 38d4947..a9aa8df 100644
--- a/src/modules/user/application/register_user/validation.py
+++ b/src/modules/user/application/auth/register_user/validation.py
@@ -1,4 +1,4 @@
-from src.modules.user.application.register_user.command import RegisterUserCommand
+from src.modules.user.application.auth.register_user.command import RegisterUserCommand
def validate_register_user_command(command: RegisterUserCommand) -> None:
diff --git a/src/modules/user/application/auth/two_factor/command.py b/src/modules/user/application/auth/two_factor/command.py
new file mode 100644
index 0000000..e53e958
--- /dev/null
+++ b/src/modules/user/application/auth/two_factor/command.py
@@ -0,0 +1,46 @@
+"""Commands for two-factor authentication operations."""
+
+from pydantic import BaseModel
+from typing import Literal
+from uuid import UUID
+
+
+class SetupTOTPCommand(BaseModel):
+ """Command to set up TOTP 2FA."""
+ user_id: UUID
+
+
+class VerifyTOTPSetupCommand(BaseModel):
+ """Command to verify and enable TOTP 2FA."""
+ user_id: UUID
+ code: str
+
+
+class DisableTOTPCommand(BaseModel):
+ """Command to disable TOTP 2FA."""
+ user_id: UUID
+ code: str
+
+
+class SendEmail2FACodeCommand(BaseModel):
+ """Command to send a 2FA code via email."""
+ user_id: UUID
+
+
+class VerifyEmail2FACodeCommand(BaseModel):
+ """Command to verify an email-based 2FA code."""
+ user_id: UUID
+ code: str
+
+
+class RegenerateBackupCodesCommand(BaseModel):
+ """Command to regenerate backup codes."""
+ user_id: UUID
+ verify_code: str
+
+
+class Verify2FACommand(BaseModel):
+ """Command to verify a 2FA code during login."""
+ user_id: UUID
+ code: str
+ method: Literal["totp", "email", "backup"] = "totp"
diff --git a/src/modules/user/application/auth/two_factor/handler.py b/src/modules/user/application/auth/two_factor/handler.py
new file mode 100644
index 0000000..6630df5
--- /dev/null
+++ b/src/modules/user/application/auth/two_factor/handler.py
@@ -0,0 +1,178 @@
+"""Handlers for two-factor authentication operations."""
+
+from src.core.email.service import EmailService
+from src.modules.user.application.auth.two_factor.command import (
+ DisableTOTPCommand,
+ RegenerateBackupCodesCommand,
+ SendEmail2FACodeCommand,
+ SetupTOTPCommand,
+ Verify2FACommand,
+ VerifyEmail2FACodeCommand,
+ VerifyTOTPSetupCommand,
+)
+from src.modules.user.domain.repositories.user_repository import UserRepository
+from src.shared.unit_of_work import UnitOfWork
+
+
+class SetupTOTPHandler:
+ """Handler for setting up TOTP 2FA."""
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: SetupTOTPCommand) -> dict[str, str]:
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ )
+ return await service.setup_totp(command.user_id)
+
+
+class VerifyTOTPSetupHandler:
+ """Handler for verifying and enabling TOTP 2FA."""
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+
+ async def execute(
+ self, command: VerifyTOTPSetupCommand
+ ) -> dict[str, list[str]]:
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ )
+ return await service.verify_totp_setup(command.user_id, command.code)
+
+
+class DisableTOTPHandler:
+ """Handler for disabling TOTP 2FA."""
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: DisableTOTPCommand) -> bool:
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ )
+ return await service.disable_totp(command.user_id, command.code)
+
+
+class SendEmail2FACodeHandler:
+ """Handler for sending email-based 2FA codes."""
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ email_service: EmailService | None = None,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+ self._email_service = email_service
+
+ async def execute(self, command: SendEmail2FACodeCommand) -> bool:
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ email_service=self._email_service,
+ )
+ return await service.send_email_2fa_code(command.user_id)
+
+
+class VerifyEmail2FACodeHandler:
+ """Handler for verifying email-based 2FA codes."""
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: VerifyEmail2FACodeCommand) -> bool:
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ )
+ return await service.verify_email_2fa_code(command.user_id, command.code)
+
+
+class RegenerateBackupCodesHandler:
+ """Handler for regenerating backup codes."""
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+
+ async def execute(
+ self, command: RegenerateBackupCodesCommand
+ ) -> dict[str, list[str]]:
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ )
+ return await service.regenerate_backup_codes(
+ command.user_id, command.verify_code
+ )
+
+
+class Verify2FAHandler:
+ """Handler for verifying 2FA codes during login."""
+
+ def __init__(
+ self,
+ user_repository: UserRepository,
+ unit_of_work: UnitOfWork,
+ ):
+ self._user_repository = user_repository
+ self._unit_of_work = unit_of_work
+
+ async def execute(self, command: Verify2FACommand) -> bool:
+ from src.core.security.two_factor_auth import TwoFactorAuthService
+
+ user = await self._user_repository.get_by_id_with_relations(command.user_id)
+ if not user:
+ raise ValueError("User not found")
+
+ service = TwoFactorAuthService(
+ user_repository=self._user_repository,
+ unit_of_work=self._unit_of_work,
+ )
+ return await service.verify_2fa_code(
+ user=user,
+ code=command.code,
+ method=command.method,
+ )
diff --git a/src/modules/user/application/detail_user/handler.py b/src/modules/user/application/detail_user/handler.py
index 4fb3061..c224334 100644
--- a/src/modules/user/application/detail_user/handler.py
+++ b/src/modules/user/application/detail_user/handler.py
@@ -12,15 +12,8 @@ def __init__(self, user_repository: UserRepository):
async def execute(self, query: DetailUserQuery) -> User:
validate_detail_user_query(query)
- user = await self._user_repository.get_by_id(query.user_id)
+ user = await self._user_repository.get_by_id_with_relations(query.user_id)
if user is None:
raise UserNotFoundError("User not found.")
- return User(
- id=user.id,
- username=user.username,
- fullname=user.fullname,
- email=user.email,
- password=user.password,
- birthday=user.birthday,
- )
+ return user
diff --git a/src/modules/user/application/events/emails/handler.py b/src/modules/user/application/events/emails/handler.py
new file mode 100644
index 0000000..fc8cdae
--- /dev/null
+++ b/src/modules/user/application/events/emails/handler.py
@@ -0,0 +1,71 @@
+from src.core.config import settings
+from src.core.email.service import EmailService
+from src.modules.user.domain.events.emails.event import (
+ PasswordResetRequestedEvent,
+ UserRegisteredEvent,
+ WelcomeEmailEvent,
+)
+from src.shared.events.handler import EventHandler
+
+
+class SendVerificationEmailHandler(EventHandler):
+ """Sends verification email when user registers"""
+
+ def __init__(self, email_service: EmailService):
+ self.email_service = email_service
+
+ async def handle(self, event: UserRegisteredEvent):
+ verification_url = (
+ f"{settings.FRONTEND_URL}/verify-email?token={event.verification_token}"
+ )
+
+ success = await self.email_service.send_email(
+ to=event.email,
+ subject="Verify your email address",
+ template_name="verification.html",
+ template_context={
+ "verification_url": verification_url,
+ "user_email": event.email,
+ },
+ )
+
+ if not success:
+ print(f"Failed to send verification email to {event.email}")
+
+
+class SendPasswordResetEmailHandler(EventHandler):
+ """Sends password reset email"""
+
+ def __init__(self, email_service: EmailService):
+ self.email_service = email_service
+
+ async def handle(self, event: PasswordResetRequestedEvent):
+ reset_url = f"{settings.FRONTEND_URL}/reset-password?token={event.reset_token}"
+
+ success = await self.email_service.send_email(
+ to=event.email,
+ subject="Reset your password",
+ template_name="password_reset.html",
+ template_context={"reset_url": reset_url, "user_email": event.email},
+ )
+
+ if not success:
+ print(f"Failed to send password reset email to {event.email}")
+
+
+class SendWelcomeEmailHandler(EventHandler):
+ """Sends welcome email after verification"""
+
+ def __init__(self, email_service: EmailService):
+ self.email_service = email_service
+
+ async def handle(self, event: WelcomeEmailEvent):
+ success = await self.email_service.send_email(
+ to=event.email,
+ subject="Welcome to Todo Modulith!",
+ template_name="welcome.html",
+ template_context={"username": event.username, "user_email": event.email},
+ )
+
+ if not success:
+ print(f"Failed to send welcome email to {event.email}")
diff --git a/src/modules/user/domain/entities/user.py b/src/modules/user/domain/entities/user.py
index 224341f..10c1af4 100644
--- a/src/modules/user/domain/entities/user.py
+++ b/src/modules/user/domain/entities/user.py
@@ -1,34 +1,86 @@
from __future__ import annotations
-from dataclasses import dataclass
-from datetime import date
+from dataclasses import dataclass, field
+from datetime import date, datetime
+from typing import Optional
from uuid import UUID, uuid4
+@dataclass
+class UserProfile:
+ """User profile containing personal information."""
+
+ user_id: UUID
+ first_name: Optional[str] = None
+ last_name: Optional[str] = None
+ display_name: Optional[str] = None
+ avatar_url: Optional[str] = None
+ bio: Optional[str] = None
+ birth_date: Optional[date] = None
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+
+@dataclass
+class UserSettings:
+ """User preferences and settings."""
+
+ user_id: UUID
+ preferences: dict = field(default_factory=dict)
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+
+@dataclass
+class UserSecurity:
+ """User security configuration and state."""
+
+ user_id: UUID
+ failed_login_attempts: int = 0
+ locked_until: Optional[datetime] = None
+ password_changed_at: Optional[datetime] = None
+ two_factor_enabled: bool = False
+ two_factor_secret: Optional[str] = None
+ two_factor_backup_codes: Optional[str] = None
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
+
+
@dataclass
class User:
+ """Core user identity and authentication aggregate root."""
+
id: UUID
email: str
- password: str
+ password_hash: str
+
+ # Identity
+ username: Optional[str] = None
+ auth_provider: str = "local"
+ external_id: Optional[str] = None
+ status: str = "pending_verification"
+
+ # Related entities (loaded separately via repository methods)
+ profile: Optional[UserProfile] = None
+ settings: Optional[UserSettings] = None
+ security: Optional[UserSecurity] = None
- username: str | None = None
- fullname: str | None = None
- birthday: date | None = None
+ created_at: Optional[str] = None
+ updated_at: Optional[str] = None
@classmethod
def create(
cls,
email: str,
- password: str,
- username: str | None = None,
- fullname: str | None = None,
- birthday: date | None = None,
+ password_hash: str,
+ username: Optional[str] = None,
+ auth_provider: str = "local",
) -> User:
return cls(
id=uuid4(),
email=email,
- password=password,
+ password_hash=password_hash,
username=username,
- fullname=fullname,
- birthday=birthday,
+ auth_provider=auth_provider,
+ status="pending_verification",
)
diff --git a/src/modules/user/domain/events/emails/event.py b/src/modules/user/domain/events/emails/event.py
new file mode 100644
index 0000000..3e791de
--- /dev/null
+++ b/src/modules/user/domain/events/emails/event.py
@@ -0,0 +1,30 @@
+from dataclasses import dataclass
+
+from src.shared.events.base import Event
+
+
+@dataclass
+class UserRegisteredEvent(Event):
+ """Fired when a new user registers"""
+
+ user_id: str
+ email: str
+ verification_token: str
+
+
+@dataclass
+class PasswordResetRequestedEvent(Event):
+ """Fired when user requests password reset"""
+
+ user_id: str
+ email: str
+ reset_token: str
+
+
+@dataclass
+class WelcomeEmailEvent(Event):
+ """Fired after email verification"""
+
+ user_id: str
+ email: str
+ username: str
diff --git a/src/modules/user/domain/repositories/user_repository.py b/src/modules/user/domain/repositories/user_repository.py
index c081a03..44ff69c 100644
--- a/src/modules/user/domain/repositories/user_repository.py
+++ b/src/modules/user/domain/repositories/user_repository.py
@@ -1,18 +1,36 @@
from abc import ABC, abstractmethod
+from typing import Optional
from uuid import UUID
-from src.modules.user.domain.entities.user import User
+from src.modules.user.domain.entities.user import User, UserProfile, UserSettings, UserSecurity
class UserRepository(ABC):
@abstractmethod
- async def get_by_email(self, email: str) -> User | None:
+ async def get_by_email(self, email: str) -> Optional[User]:
pass
@abstractmethod
- async def get_by_id(self, user_id: UUID) -> User | None:
+ async def get_by_id(self, user_id: UUID) -> Optional[User]:
+ pass
+
+ @abstractmethod
+ async def get_by_id_with_relations(self, user_id: UUID) -> Optional[User]:
+ """Get user with profile, settings, and security loaded."""
pass
@abstractmethod
async def save(self, user: User) -> User:
pass
+
+ @abstractmethod
+ async def save_profile(self, profile: UserProfile) -> UserProfile:
+ pass
+
+ @abstractmethod
+ async def save_settings(self, settings: UserSettings) -> UserSettings:
+ pass
+
+ @abstractmethod
+ async def save_security(self, security: UserSecurity) -> UserSecurity:
+ pass
diff --git a/src/modules/user/infrastructure/models/__init__.py b/src/modules/user/infrastructure/models/__init__.py
new file mode 100644
index 0000000..80f4b1a
--- /dev/null
+++ b/src/modules/user/infrastructure/models/__init__.py
@@ -0,0 +1,34 @@
+"""Normalized user domain models following DDD principles.
+
+This module contains all SQLAlchemy models for the normalized user domain:
+- users: Core identity and authentication
+- user_profiles: Personal information
+- user_contacts: Multiple contact methods
+- user_addresses: Multiple addresses
+- user_settings: User preferences (JSONB)
+- user_security: Security configuration
+- user_verifications: Verification status
+- user_sessions: Session management
+"""
+
+from .user_model import UserModel, UserStatus, AuthProvider
+from .user_profile_model import UserProfileModel
+from .user_contact_model import UserContactModel
+from .user_address_model import UserAddressModel
+from .user_settings_model import UserSettingsModel
+from .user_security_model import UserSecurityModel
+from .user_verification_model import UserVerificationModel
+from .refresh_token_model import UserSessionModel
+
+__all__ = [
+ "UserModel",
+ "UserStatus",
+ "AuthProvider",
+ "UserProfileModel",
+ "UserContactModel",
+ "UserAddressModel",
+ "UserSettingsModel",
+ "UserSecurityModel",
+ "UserVerificationModel",
+ "UserSessionModel",
+]
diff --git a/src/modules/user/infrastructure/models/refresh_token_model.py b/src/modules/user/infrastructure/models/refresh_token_model.py
index bda5184..b3bbfe1 100644
--- a/src/modules/user/infrastructure/models/refresh_token_model.py
+++ b/src/modules/user/infrastructure/models/refresh_token_model.py
@@ -1,21 +1,61 @@
-import uuid
from datetime import datetime
+from uuid import UUID
-from sqlalchemy import Boolean, DateTime, ForeignKey, String
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
from src.shared.database.model import Base
-class RefreshTokenModel(
- Base,
- TimeStampMixin,
- SoftDeleteMixin,
-):
- __tablename__ = "refresh_tokens"
+class UserSessionModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """User session management for tracking active sessions.
- user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True)
- token_hash: Mapped[str] = mapped_column(String(255), unique=True, index=True)
- expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
- is_revoked: Mapped[bool] = mapped_column(Boolean, default=False)
+ One-to-many relationship with users table.
+ Stores refresh tokens, device info, and login history.
+ """
+
+ __tablename__ = "user_sessions"
+ __table_args__ = (
+ Index("ix_user_sessions_user_id", "user_id"),
+ Index("ix_user_sessions_token_hash", "refresh_token_hash", unique=True),
+ Index("ix_user_sessions_expires_at", "expires_at"),
+ Index("ix_user_sessions_is_revoked", "is_revoked"),
+ Index("ix_user_sessions_device_info", "device_info"),
+ )
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ nullable=False,
+ )
+
+ # Session Token (hashed for security)
+ refresh_token_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+
+ # Session Expiry
+ expires_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False
+ )
+
+ # Device and Location Info
+ device_info: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ ip_address: Mapped[str | None] = mapped_column(
+ String(45), nullable=True
+ ) # IPv6 compatible
+ user_agent: Mapped[str | None] = mapped_column(String(1000), nullable=True)
+
+ # Session Status
+ is_revoked: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ revoked_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+ revoked_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ # Relationship
+ user: Mapped["UserModel"] = relationship( # type: ignore[name-defined]
+ back_populates="sessions",
+ foreign_keys=[user_id],
+ )
diff --git a/src/modules/user/infrastructure/models/user_address_model.py b/src/modules/user/infrastructure/models/user_address_model.py
new file mode 100644
index 0000000..7b1c6a3
--- /dev/null
+++ b/src/modules/user/infrastructure/models/user_address_model.py
@@ -0,0 +1,58 @@
+from uuid import UUID
+
+from sqlalchemy import Boolean, ForeignKey, Index, String
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.user.infrastructure.models.user_model import UserModel
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class UserAddressModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """User addresses supporting multiple locations.
+
+ One-to-many relationship with users table.
+ Supports home, billing, shipping, and custom address labels.
+ """
+
+ __tablename__ = "user_addresses"
+ __table_args__ = (
+ Index("ix_user_addresses_user_id", "user_id"),
+ Index("ix_user_addresses_label", "label"),
+ Index("ix_user_addresses_is_default", "is_default"),
+ Index("ix_user_addresses_country", "country"),
+ )
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ nullable=False,
+ )
+
+ # Address Label (home, billing, shipping, work, etc.)
+ label: Mapped[str] = mapped_column(String(100), nullable=False)
+
+ # Address Lines
+ line1: Mapped[str] = mapped_column(String(255), nullable=False)
+ line2: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ line3: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ # City and State
+ city: Mapped[str] = mapped_column(String(100), nullable=False)
+ state: Mapped[str | None] = mapped_column(String(100), nullable=True)
+
+ # Postal Code
+ postal_code: Mapped[str] = mapped_column(String(20), nullable=False)
+
+ # Country (ISO 3166-1 alpha-2)
+ country: Mapped[str] = mapped_column(String(2), nullable=False)
+
+ # Flags
+ is_default: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ # Relationship
+ user: Mapped["UserModel"] = relationship(
+ back_populates="addresses",
+ foreign_keys=[user_id],
+ )
diff --git a/src/modules/user/infrastructure/models/user_contact_model.py b/src/modules/user/infrastructure/models/user_contact_model.py
new file mode 100644
index 0000000..4d6fde9
--- /dev/null
+++ b/src/modules/user/infrastructure/models/user_contact_model.py
@@ -0,0 +1,54 @@
+from enum import Enum
+from uuid import UUID
+
+from sqlalchemy import Boolean, ForeignKey, Index, String
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.user.infrastructure.models.user_model import UserModel
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class ContactType(str, Enum):
+ EMAIL = "email"
+ PHONE = "phone"
+ MOBILE = "mobile"
+ WORK_PHONE = "work_phone"
+ HOME_PHONE = "home_phone"
+ OTHER = "other"
+
+
+class UserContactModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """User contact methods supporting multiple channels.
+
+ One-to-many relationship with users table.
+ Allows users to have multiple contact methods (phones, alternate emails).
+ """
+
+ __tablename__ = "user_contacts"
+ __table_args__ = (
+ Index("ix_user_contacts_user_id", "user_id"),
+ Index("ix_user_contacts_type", "contact_type"),
+ Index("ix_user_contacts_is_primary", "is_primary"),
+ )
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ nullable=False,
+ )
+
+ # Contact Information
+ contact_type: Mapped[str] = mapped_column(String(50), nullable=False)
+ value: Mapped[str] = mapped_column(String(255), nullable=False)
+
+ # Flags
+ is_primary: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+
+ # Relationship
+ user: Mapped["UserModel"] = relationship(
+ back_populates="contacts",
+ foreign_keys=[user_id],
+ )
diff --git a/src/modules/user/infrastructure/models/user_model.py b/src/modules/user/infrastructure/models/user_model.py
index b40286f..1f8f3de 100644
--- a/src/modules/user/infrastructure/models/user_model.py
+++ b/src/modules/user/infrastructure/models/user_model.py
@@ -1,21 +1,105 @@
-from datetime import date
+from enum import Enum
+from typing import TYPE_CHECKING
-from sqlalchemy import Date, String
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy import Index, String
+from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
from src.shared.database.model import Base
+if TYPE_CHECKING:
+ from src.modules.authorization.infrastructure.models.user_has_role_model import (
+ UserHasRoleModel,
+ )
+
+
+class UserStatus(str, Enum):
+ ACTIVE = "active"
+ INACTIVE = "inactive"
+ SUSPENDED = "suspended"
+ PENDING_VERIFICATION = "pending_verification"
+
+
+class AuthProvider(str, Enum):
+ LOCAL = "local"
+ GOOGLE = "google"
+ GITHUB = "github"
+ MICROSOFT = "microsoft"
+ SSO = "sso"
+
class UserModel(
Base,
TimeStampMixin,
SoftDeleteMixin,
):
+ """Core user identity and authentication table.
+
+ Contains only identity and authentication related fields.
+ All other user data is in separate normalized tables.
+ """
+
__tablename__ = "users"
+ __table_args__ = (
+ Index("ix_users_status", "status"),
+ Index("ix_users_auth_provider", "auth_provider"),
+ )
+
+ # Identity
+ email: Mapped[str] = mapped_column(
+ String(255), unique=True, index=True, nullable=False
+ )
+ username: Mapped[str | None] = mapped_column(
+ String(100), unique=True, index=True, nullable=True
+ )
+
+ # Authentication
+ password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ auth_provider: Mapped[str] = mapped_column(
+ String(50), default=AuthProvider.LOCAL, nullable=False
+ )
+ external_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ # Status
+ status: Mapped[str] = mapped_column(
+ String(50), default=UserStatus.PENDING_VERIFICATION, nullable=False
+ )
+
+ # Relationships (one-to-one)
+ profile: Mapped["UserProfileModel"] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ uselist=False,
+ cascade="all, delete-orphan",
+ )
+ security: Mapped["UserSecurityModel"] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ uselist=False,
+ cascade="all, delete-orphan",
+ )
+ settings: Mapped["UserSettingsModel"] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ uselist=False,
+ cascade="all, delete-orphan",
+ )
- username: Mapped[str | None] = mapped_column(String(255), nullable=True)
- fullname: Mapped[str | None] = mapped_column(String(255), nullable=True)
- birthday: Mapped[date | None] = mapped_column(Date, nullable=True)
- email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
- password: Mapped[str] = mapped_column(String(255))
+ # Relationships (one-to-many)
+ contacts: Mapped[list["UserContactModel"]] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+ addresses: Mapped[list["UserAddressModel"]] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+ verifications: Mapped[list["UserVerificationModel"]] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+ sessions: Mapped[list["UserSessionModel"]] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
+ role_assignments: Mapped[list["UserHasRoleModel"]] = relationship( # type: ignore[name-defined]
+ back_populates="user",
+ cascade="all, delete-orphan",
+ )
diff --git a/src/modules/user/infrastructure/models/user_profile_model.py b/src/modules/user/infrastructure/models/user_profile_model.py
new file mode 100644
index 0000000..1609c5a
--- /dev/null
+++ b/src/modules/user/infrastructure/models/user_profile_model.py
@@ -0,0 +1,45 @@
+from uuid import UUID
+
+from sqlalchemy import Date, ForeignKey, Index, String, Text
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.user.infrastructure.models.user_model import UserModel
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class UserProfileModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """User profile containing personal information.
+
+ One-to-one relationship with users table.
+ Contains fields that are not required for authentication.
+ """
+
+ __tablename__ = "user_profiles"
+ __table_args__ = (Index("ix_user_profiles_user_id", "user_id", unique=True),)
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ unique=True,
+ nullable=False,
+ )
+
+ # Personal Information
+ first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
+ display_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
+
+ # Avatar and Bio
+ avatar_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+ bio: Mapped[str | None] = mapped_column(Text, nullable=True)
+
+ # Birth Date
+ birth_date: Mapped[Date | None] = mapped_column(Date, nullable=True)
+
+ # Relationship
+ user: Mapped["UserModel"] = relationship(
+ back_populates="profile",
+ foreign_keys=[user_id],
+ )
diff --git a/src/modules/user/infrastructure/models/user_security_model.py b/src/modules/user/infrastructure/models/user_security_model.py
new file mode 100644
index 0000000..868d9ef
--- /dev/null
+++ b/src/modules/user/infrastructure/models/user_security_model.py
@@ -0,0 +1,72 @@
+from datetime import datetime
+from uuid import UUID
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.user.infrastructure.models.user_model import UserModel
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class UserSecurityModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """User security configuration and state.
+
+ One-to-one relationship with users table.
+ Contains sensitive security-related fields separated from core identity.
+ """
+
+ __tablename__ = "user_security"
+ __table_args__ = (
+ Index("ix_user_security_user_id", "user_id", unique=True),
+ Index("ix_user_security_locked_until", "locked_until"),
+ Index("ix_user_security_two_factor_enabled", "two_factor_enabled"),
+ )
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ unique=True,
+ nullable=False,
+ )
+
+ # Login Attempt Tracking
+ failed_login_attempts: Mapped[int] = mapped_column(
+ Integer,
+ default=0,
+ nullable=False,
+ )
+
+ # Account Lockout
+ locked_until: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ # Password Management
+ password_changed_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ # Two-Factor Authentication
+ two_factor_enabled: Mapped[bool] = mapped_column(
+ Boolean,
+ default=False,
+ nullable=False,
+ )
+ two_factor_secret: Mapped[str | None] = mapped_column(
+ String(255),
+ nullable=True,
+ )
+ two_factor_backup_codes: Mapped[str | None] = mapped_column(
+ String(1000),
+ nullable=True,
+ )
+
+ # Relationship
+ user: Mapped["UserModel"] = relationship(
+ back_populates="security",
+ foreign_keys=[user_id],
+ )
diff --git a/src/modules/user/infrastructure/models/user_settings_model.py b/src/modules/user/infrastructure/models/user_settings_model.py
new file mode 100644
index 0000000..5c07f8f
--- /dev/null
+++ b/src/modules/user/infrastructure/models/user_settings_model.py
@@ -0,0 +1,61 @@
+from uuid import UUID
+
+from sqlalchemy import ForeignKey, Index
+from sqlalchemy.dialects.postgresql import JSONB
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.user.infrastructure.models.user_model import UserModel
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class UserSettingsModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """User preferences and settings stored as JSONB.
+
+ One-to-one relationship with users table.
+ Flexible schema allows adding new preferences without migrations.
+
+ Example preferences structure:
+ {
+ "language": "en",
+ "timezone": "UTC",
+ "theme": "dark",
+ "currency": "USD",
+ "notifications": {
+ "email": true,
+ "push": false,
+ "sms": false
+ },
+ "privacy": {
+ "profile_visibility": "public",
+ "show_email": false
+ }
+ }
+ """
+
+ __tablename__ = "user_settings"
+ __table_args__ = (
+ Index("ix_user_settings_user_id", "user_id", unique=True),
+ Index("ix_user_settings_preferences", "preferences", postgresql_using="gin"),
+ )
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ unique=True,
+ nullable=False,
+ )
+
+ # Preferences stored as JSONB for flexibility
+ preferences: Mapped[dict] = mapped_column(
+ JSONB,
+ default=dict,
+ nullable=False,
+ )
+
+ # Relationship
+ user: Mapped["UserModel"] = relationship(
+ back_populates="settings",
+ foreign_keys=[user_id],
+ )
diff --git a/src/modules/user/infrastructure/models/user_verification_model.py b/src/modules/user/infrastructure/models/user_verification_model.py
new file mode 100644
index 0000000..0754e74
--- /dev/null
+++ b/src/modules/user/infrastructure/models/user_verification_model.py
@@ -0,0 +1,55 @@
+from datetime import datetime
+from uuid import UUID
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Index, String
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+
+from src.modules.user.infrastructure.models.user_model import UserModel
+from src.shared.database.mixin.timestamp import SoftDeleteMixin, TimeStampMixin
+from src.shared.database.model import Base
+
+
+class UserVerificationModel(Base, TimeStampMixin, SoftDeleteMixin):
+ """User verification status per communication channel.
+
+ One-to-many relationship with users table.
+ Tracks verification status for email, phone, and other channels.
+ """
+
+ __tablename__ = "user_verifications"
+ __table_args__ = (
+ Index("ix_user_verifications_user_id", "user_id"),
+ Index("ix_user_verifications_channel", "channel"),
+ Index("ix_user_verifications_is_verified", "is_verified"),
+ Index("ix_user_verifications_token", "verification_token"),
+ )
+
+ user_id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
+ ForeignKey("users.id"),
+ nullable=False,
+ )
+
+ # Verification Channel (email, phone, etc.)
+ channel: Mapped[str] = mapped_column(String(50), nullable=False)
+
+ # Verification Status
+ is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
+ verified_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ # Verification Token (for pending verifications)
+ verification_token: Mapped[str | None] = mapped_column(String(255), nullable=True)
+ token_expires_at: Mapped[datetime | None] = mapped_column(
+ DateTime(timezone=True),
+ nullable=True,
+ )
+
+ # Relationship
+ user: Mapped["UserModel"] = relationship(
+ back_populates="verifications",
+ foreign_keys=[user_id],
+ )
diff --git a/src/modules/user/infrastructure/repositories/refresh_token_repository.py b/src/modules/user/infrastructure/repositories/refresh_token_repository.py
index bed35b3..66a018c 100644
--- a/src/modules/user/infrastructure/repositories/refresh_token_repository.py
+++ b/src/modules/user/infrastructure/repositories/refresh_token_repository.py
@@ -6,7 +6,9 @@
from src.modules.user.domain.repositories.refresh_token_repository import (
RefreshTokenRepository,
)
-from src.modules.user.infrastructure.models.refresh_token_model import RefreshTokenModel
+from src.modules.user.infrastructure.models.refresh_token_model import (
+ UserSessionModel as RefreshTokenModel,
+)
class SQLAlchemyRefreshTokenRepository(RefreshTokenRepository):
@@ -15,7 +17,9 @@ def __init__(self, db):
async def get_by_token_hash(self, token_hash: str) -> RefreshToken | None:
result = await self.db.execute(
- select(RefreshTokenModel).where(RefreshTokenModel.token_hash == token_hash)
+ select(RefreshTokenModel).where(
+ RefreshTokenModel.refresh_token_hash == token_hash
+ )
)
model = result.scalar_one_or_none()
if not model:
@@ -23,7 +27,7 @@ async def get_by_token_hash(self, token_hash: str) -> RefreshToken | None:
return RefreshToken(
id=model.id,
user_id=model.user_id,
- token_hash=model.token_hash,
+ token_hash=model.refresh_token_hash,
expires_at=model.expires_at,
is_revoked=model.is_revoked,
)
@@ -32,7 +36,7 @@ async def save(self, refresh_token: RefreshToken) -> RefreshToken:
model = RefreshTokenModel(
id=refresh_token.id,
user_id=refresh_token.user_id,
- token_hash=refresh_token.token_hash,
+ refresh_token_hash=refresh_token.token_hash,
expires_at=refresh_token.expires_at,
is_revoked=refresh_token.is_revoked,
)
@@ -42,7 +46,7 @@ async def save(self, refresh_token: RefreshToken) -> RefreshToken:
return RefreshToken(
id=model.id,
user_id=model.user_id,
- token_hash=model.token_hash,
+ token_hash=model.refresh_token_hash,
expires_at=model.expires_at,
is_revoked=model.is_revoked,
)
diff --git a/src/modules/user/infrastructure/repositories/user_repository.py b/src/modules/user/infrastructure/repositories/user_repository.py
index 20bd2cf..386a10d 100644
--- a/src/modules/user/infrastructure/repositories/user_repository.py
+++ b/src/modules/user/infrastructure/repositories/user_repository.py
@@ -1,18 +1,28 @@
+from typing import Optional
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
-from src.modules.user.domain.entities.user import User
+from src.modules.user.domain.entities.user import (
+ User,
+ UserProfile,
+ UserSecurity,
+ UserSettings,
+)
from src.modules.user.domain.repositories.user_repository import UserRepository
from src.modules.user.infrastructure.models.user_model import UserModel
+from src.modules.user.infrastructure.models.user_profile_model import UserProfileModel
+from src.modules.user.infrastructure.models.user_security_model import UserSecurityModel
+from src.modules.user.infrastructure.models.user_settings_model import UserSettingsModel
class SQLAlchemyUserRepository(UserRepository):
def __init__(self, db: AsyncSession):
self._db = db
- async def get_by_email(self, email) -> User | None:
+ async def get_by_email(self, email: str) -> Optional[User]:
result = await self._db.execute(
select(UserModel).where(UserModel.email == email)
)
@@ -20,46 +30,243 @@ async def get_by_email(self, email) -> User | None:
if user_model is None:
return None
- return User(
- id=user_model.id,
- email=user_model.email,
- password=user_model.password,
- username=user_model.username,
- fullname=user_model.fullname,
- birthday=user_model.birthday,
- )
+ return self._map_to_entity(user_model)
- async def get_by_id(self, user_id: UUID) -> User | None:
- result = await self._db.execute(select(UserModel).where(UserModel.id == user_id))
+ async def get_by_id(self, user_id: UUID) -> Optional[User]:
+ result = await self._db.execute(
+ select(UserModel).where(UserModel.id == user_id)
+ )
user_model = result.scalar_one_or_none()
if not user_model:
return None
- return User(
- id=user_model.id,
- email=user_model.email,
- password=user_model.password,
- username=user_model.username,
- fullname=user_model.fullname,
- birthday=user_model.birthday,
+ return self._map_to_entity(user_model)
+
+ async def get_by_id_with_relations(self, user_id: UUID) -> Optional[User]:
+ """Get user with profile, settings, and security eagerly loaded."""
+ result = await self._db.execute(
+ select(UserModel)
+ .options(
+ selectinload(UserModel.profile),
+ selectinload(UserModel.settings),
+ selectinload(UserModel.security),
+ )
+ .where(UserModel.id == user_id)
)
+ user_model = result.scalar_one_or_none()
+ if not user_model:
+ return None
+ return self._map_to_entity_with_relations(user_model)
async def save(self, user: User) -> User:
- user_model = UserModel(
- id=user.id,
- email=user.email,
- password=user.password,
- username=user.username,
- fullname=user.fullname,
- birthday=user.birthday,
- )
- self._db.add(user_model)
+ # Check if user exists
+ existing = await self.get_by_id(user.id)
+
+ if existing:
+ # Update existing user
+ user_model = await self._get_user_model(user.id)
+ user_model.email = user.email
+ user_model.username = user.username
+ user_model.password_hash = user.password_hash
+ user_model.auth_provider = user.auth_provider
+ user_model.status = user.status
+ user_model.external_id = user.external_id
+ else:
+ # Create new user
+ user_model = UserModel(
+ id=user.id,
+ email=user.email,
+ username=user.username,
+ password_hash=user.password_hash,
+ auth_provider=user.auth_provider,
+ status=user.status,
+ external_id=user.external_id,
+ )
+ self._db.add(user_model)
+
+ # Create default related records
+ await self._create_default_related_records(user_model.id)
+
await self._db.flush()
await self._db.refresh(user_model)
+ return self._map_to_entity(user_model)
+
+ async def save_profile(self, profile: UserProfile) -> UserProfile:
+ existing = await self._db.execute(
+ select(UserProfileModel).where(UserProfileModel.user_id == profile.user_id)
+ )
+ profile_model = existing.scalar_one_or_none()
+
+ if profile_model:
+ profile_model.first_name = profile.first_name
+ profile_model.last_name = profile.last_name
+ profile_model.display_name = profile.display_name
+ profile_model.avatar_url = profile.avatar_url
+ profile_model.bio = profile.bio
+ profile_model.birth_date = profile.birth_date
+ else:
+ profile_model = UserProfileModel(
+ user_id=profile.user_id,
+ first_name=profile.first_name,
+ last_name=profile.last_name,
+ display_name=profile.display_name,
+ avatar_url=profile.avatar_url,
+ bio=profile.bio,
+ birth_date=profile.birth_date,
+ )
+ self._db.add(profile_model)
+
+ await self._db.flush()
+ await self._db.refresh(profile_model)
+ return self._map_profile_to_entity(profile_model)
+
+ async def save_settings(self, settings: UserSettings) -> UserSettings:
+ existing = await self._db.execute(
+ select(UserSettingsModel).where(
+ UserSettingsModel.user_id == settings.user_id
+ )
+ )
+ settings_model = existing.scalar_one_or_none()
+
+ if settings_model:
+ settings_model.preferences = settings.preferences
+ else:
+ settings_model = UserSettingsModel(
+ user_id=settings.user_id,
+ preferences=settings.preferences,
+ )
+ self._db.add(settings_model)
+
+ await self._db.flush()
+ await self._db.refresh(settings_model)
+ return self._map_settings_to_entity(settings_model)
+
+ async def save_security(self, security: UserSecurity) -> UserSecurity:
+ existing = await self._db.execute(
+ select(UserSecurityModel).where(
+ UserSecurityModel.user_id == security.user_id
+ )
+ )
+ security_model = existing.scalar_one_or_none()
+
+ if security_model:
+ security_model.failed_login_attempts = security.failed_login_attempts
+ security_model.locked_until = security.locked_until
+ security_model.password_changed_at = security.password_changed_at
+ security_model.two_factor_enabled = security.two_factor_enabled
+ security_model.two_factor_secret = security.two_factor_secret
+ security_model.two_factor_backup_codes = security.two_factor_backup_codes
+ else:
+ security_model = UserSecurityModel(
+ user_id=security.user_id,
+ failed_login_attempts=security.failed_login_attempts,
+ locked_until=security.locked_until,
+ password_changed_at=security.password_changed_at,
+ two_factor_enabled=security.two_factor_enabled,
+ two_factor_secret=security.two_factor_secret,
+ two_factor_backup_codes=security.two_factor_backup_codes,
+ )
+ self._db.add(security_model)
+
+ await self._db.flush()
+ await self._db.refresh(security_model)
+ return self._map_security_to_entity(security_model)
+
+ async def _get_user_model(self, user_id: UUID) -> UserModel:
+ result = await self._db.execute(
+ select(UserModel).where(UserModel.id == user_id)
+ )
+ return result.scalar_one()
+
+ async def _create_default_related_records(self, user_id: UUID) -> None:
+ """Create default profile, settings, and security records for a new user."""
+ # Default profile
+ profile_model = UserProfileModel(user_id=user_id)
+ self._db.add(profile_model)
+
+ # Default settings
+ settings_model = UserSettingsModel(
+ user_id=user_id,
+ preferences={
+ "language": "en",
+ "timezone": "UTC",
+ "theme": "light",
+ "notifications": {
+ "email": True,
+ "push": False,
+ },
+ },
+ )
+ self._db.add(settings_model)
+
+ # Default security
+ security_model = UserSecurityModel(
+ user_id=user_id,
+ failed_login_attempts=0,
+ two_factor_enabled=False,
+ )
+ self._db.add(security_model)
+
+ def _map_to_entity(self, user_model: UserModel) -> User:
return User(
id=user_model.id,
email=user_model.email,
- password=user_model.password,
+ password_hash=user_model.password_hash,
username=user_model.username,
- fullname=user_model.fullname,
- birthday=user_model.birthday,
+ auth_provider=user_model.auth_provider,
+ status=user_model.status,
+ external_id=user_model.external_id,
+ created_at=user_model.created_at.isoformat(),
+ updated_at=user_model.updated_at.isoformat(),
+ )
+
+ def _map_to_entity_with_relations(self, user_model: UserModel) -> User:
+ user = self._map_to_entity(user_model)
+
+ if user_model.profile:
+ user.profile = self._map_profile_to_entity(user_model.profile)
+
+ if user_model.settings:
+ user.settings = self._map_settings_to_entity(user_model.settings)
+
+ if user_model.security:
+ user.security = self._map_security_to_entity(user_model.security)
+
+ return user
+
+ def _map_profile_to_entity(self, profile_model: UserProfileModel) -> UserProfile:
+ return UserProfile(
+ user_id=profile_model.user_id,
+ first_name=profile_model.first_name,
+ last_name=profile_model.last_name,
+ display_name=profile_model.display_name,
+ avatar_url=profile_model.avatar_url,
+ bio=profile_model.bio,
+ birth_date=profile_model.birth_date,
+ created_at=profile_model.created_at.isoformat(),
+ updated_at=profile_model.updated_at.isoformat(),
+ )
+
+ def _map_settings_to_entity(
+ self, settings_model: UserSettingsModel
+ ) -> UserSettings:
+ return UserSettings(
+ user_id=settings_model.user_id,
+ preferences=settings_model.preferences or {},
+ created_at=settings_model.created_at.isoformat(),
+ updated_at=settings_model.updated_at.isoformat(),
+ )
+
+ def _map_security_to_entity(
+ self, security_model: UserSecurityModel
+ ) -> UserSecurity:
+ return UserSecurity(
+ user_id=security_model.user_id,
+ failed_login_attempts=security_model.failed_login_attempts,
+ locked_until=security_model.locked_until,
+ password_changed_at=security_model.password_changed_at,
+ two_factor_enabled=security_model.two_factor_enabled,
+ two_factor_secret=security_model.two_factor_secret,
+ two_factor_backup_codes=security_model.two_factor_backup_codes,
+ created_at=security_model.created_at.isoformat(),
+ updated_at=security_model.updated_at.isoformat(),
)
diff --git a/src/modules/user/presentation/dependency.py b/src/modules/user/presentation/dependency.py
index eb58461..fd9cfe3 100644
--- a/src/modules/user/presentation/dependency.py
+++ b/src/modules/user/presentation/dependency.py
@@ -1,9 +1,9 @@
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
-from src.core.authorization.dependencies import get_authorization_service
-from src.core.authorization.domain.service import AuthorizationService
from src.core.database.postgres.session import get_db, get_unit_of_work
+from src.core.email.factory import create_email_service
+from src.core.email.service import EmailService
from src.core.security.account_lockout import AccountLockoutService
from src.core.security.audit import AuditService
from src.core.security.infrastructure.repositories.audit_log_repository import (
@@ -13,15 +13,30 @@
SQLAlchemyLoginAttemptRepository,
)
from src.core.security.token_revocation import TokenRevocationService
-from src.modules.user.application.detail_user.handler import DetailUserQueryHandler
-from src.modules.user.application.login_user.handler import LoginUserCommandHandler
-from src.modules.user.application.logout_user.handler import LogoutUserCommandHandler
-from src.modules.user.application.refresh_token.handler import (
+from src.modules.authorization.domain.services.authorization_service import (
+ AuthorizationService,
+)
+from src.modules.authorization.presentation.dependency import get_authorization_service
+from src.modules.user.application.auth.login_user.handler import LoginUserCommandHandler
+from src.modules.user.application.auth.logout_user.handler import (
+ LogoutUserCommandHandler,
+)
+from src.modules.user.application.auth.refresh_token.handler import (
RefreshTokenCommandHandler,
)
-from src.modules.user.application.register_user.handler import (
+from src.modules.user.application.auth.register_user.handler import (
RegisterUserCommandHandler,
)
+from src.modules.user.application.auth.two_factor.handler import (
+ DisableTOTPHandler,
+ RegenerateBackupCodesHandler,
+ SendEmail2FACodeHandler,
+ SetupTOTPHandler,
+ Verify2FAHandler,
+ VerifyEmail2FACodeHandler,
+ VerifyTOTPSetupHandler,
+)
+from src.modules.user.application.detail_user.handler import DetailUserQueryHandler
from src.modules.user.domain.repositories.refresh_token_repository import (
RefreshTokenRepository,
)
@@ -39,6 +54,10 @@ def get_user_repository(db: AsyncSession = Depends(get_db)) -> UserRepository:
return SQLAlchemyUserRepository(db)
+def get_email_service() -> EmailService:
+ return create_email_service()
+
+
def get_refresh_token_repository(
db: AsyncSession = Depends(get_db),
) -> RefreshTokenRepository:
@@ -110,3 +129,56 @@ def get_logout_handler(
unit_of_work,
token_revocation_service,
)
+
+
+# Two-Factor Authentication Handlers
+
+
+def get_setup_totp_handler(
+ user_repo: UserRepository = Depends(get_user_repository),
+ unit_of_work: UnitOfWork = Depends(get_unit_of_work),
+) -> SetupTOTPHandler:
+ return SetupTOTPHandler(user_repo, unit_of_work)
+
+
+def get_verify_totp_setup_handler(
+ user_repo: UserRepository = Depends(get_user_repository),
+ unit_of_work: UnitOfWork = Depends(get_unit_of_work),
+) -> VerifyTOTPSetupHandler:
+ return VerifyTOTPSetupHandler(user_repo, unit_of_work)
+
+
+def get_disable_totp_handler(
+ user_repo: UserRepository = Depends(get_user_repository),
+ unit_of_work: UnitOfWork = Depends(get_unit_of_work),
+) -> DisableTOTPHandler:
+ return DisableTOTPHandler(user_repo, unit_of_work)
+
+
+def get_send_email_2fa_code_handler(
+ user_repo: UserRepository = Depends(get_user_repository),
+ unit_of_work: UnitOfWork = Depends(get_unit_of_work),
+ email_service=Depends(get_email_service),
+) -> SendEmail2FACodeHandler:
+ return SendEmail2FACodeHandler(user_repo, unit_of_work, email_service)
+
+
+def get_verify_email_2fa_code_handler(
+ user_repo: UserRepository = Depends(get_user_repository),
+ unit_of_work: UnitOfWork = Depends(get_unit_of_work),
+) -> VerifyEmail2FACodeHandler:
+ return VerifyEmail2FACodeHandler(user_repo, unit_of_work)
+
+
+def get_regenerate_backup_codes_handler(
+ user_repo: UserRepository = Depends(get_user_repository),
+ unit_of_work: UnitOfWork = Depends(get_unit_of_work),
+) -> RegenerateBackupCodesHandler:
+ return RegenerateBackupCodesHandler(user_repo, unit_of_work)
+
+
+def get_verify_2fa_handler(
+ user_repo: UserRepository = Depends(get_user_repository),
+ unit_of_work: UnitOfWork = Depends(get_unit_of_work),
+) -> Verify2FAHandler:
+ return Verify2FAHandler(user_repo, unit_of_work)
diff --git a/src/modules/user/presentation/routers/two_factor_router.py b/src/modules/user/presentation/routers/two_factor_router.py
new file mode 100644
index 0000000..477b0c6
--- /dev/null
+++ b/src/modules/user/presentation/routers/two_factor_router.py
@@ -0,0 +1,265 @@
+"""Router for two-factor authentication endpoints."""
+
+from fastapi import APIRouter, Depends, HTTPException
+
+from src.core.schemas.response import SuccessResponse
+from src.modules.user.application.auth.two_factor.command import (
+ DisableTOTPCommand,
+ RegenerateBackupCodesCommand,
+ SendEmail2FACodeCommand,
+ SetupTOTPCommand,
+ Verify2FACommand,
+ VerifyEmail2FACodeCommand,
+ VerifyTOTPSetupCommand,
+)
+from src.modules.user.presentation.dependency import (
+ get_current_user_id,
+ get_disable_totp_handler,
+ get_regenerate_backup_codes_handler,
+ get_send_email_2fa_code_handler,
+ get_setup_totp_handler,
+ get_verify_2fa_handler,
+ get_verify_email_2fa_code_handler,
+ get_verify_totp_setup_handler,
+)
+from src.modules.user.presentation.schemas.two_factor import (
+ DisableTOTPRequest,
+ RegenerateBackupCodesRequest,
+ SendEmail2FACodeRequest,
+ SetupTOTPRequest,
+ TwoFactorEnableResponse,
+ TwoFactorSetupResponse,
+ TwoFactorVerifyResponse,
+ Verify2FARequest,
+ VerifyEmail2FACodeRequest,
+ VerifyTOTPSetupRequest,
+)
+
+router = APIRouter(prefix="/2fa", tags=["Two-Factor Authentication"])
+
+
+@router.post(
+ "/setup/totp",
+ response_model=SuccessResponse[TwoFactorSetupResponse],
+ summary="Set up TOTP 2FA",
+ description="Generate a TOTP secret and QR code URI for authenticator apps like Google Authenticator, Authy, etc.",
+)
+async def setup_totp(
+ request: SetupTOTPRequest,
+ current_user_id: str = Depends(get_current_user_id),
+ handler: SetupTOTPHandler = Depends(get_setup_totp_handler),
+):
+ """Set up TOTP-based 2FA.
+
+ This endpoint generates a TOTP secret and returns a URI that can be used
+ to create a QR code for scanning with authenticator apps.
+
+ Compatible with:
+ - Google Authenticator
+ - Authy
+ - Microsoft Authenticator
+ - Any TOTP-compatible authenticator app
+ """
+ from uuid import UUID
+
+ command = SetupTOTPCommand(user_id=UUID(current_user_id))
+ result = await handler.execute(command)
+
+ return SuccessResponse(
+ success=True,
+ message="TOTP setup initiated. Scan the QR code with your authenticator app.",
+ data=TwoFactorSetupResponse(**result),
+ )
+
+
+@router.post(
+ "/verify/totp",
+ response_model=SuccessResponse[TwoFactorEnableResponse],
+ summary="Verify and enable TOTP 2FA",
+ description="Verify the TOTP code from your authenticator app and enable 2FA.",
+)
+async def verify_totp_setup(
+ request: VerifyTOTPSetupRequest,
+ current_user_id: str = Depends(get_current_user_id),
+ handler: VerifyTOTPSetupHandler = Depends(get_verify_totp_setup_handler),
+):
+ """Verify TOTP setup and enable 2FA.
+
+ After scanning the QR code, submit the 6-digit code from your authenticator app
+ to complete the setup. This will return backup codes - store them safely!
+ """
+ from uuid import UUID
+
+ command = VerifyTOTPSetupCommand(
+ user_id=UUID(current_user_id),
+ code=request.code,
+ )
+ result = await handler.execute(command)
+
+ return SuccessResponse(
+ success=True,
+ message="2FA enabled successfully. Store your backup codes safely!",
+ data=TwoFactorEnableResponse(**result),
+ )
+
+
+@router.post(
+ "/disable",
+ response_model=SuccessResponse[TwoFactorVerifyResponse],
+ summary="Disable 2FA",
+ description="Disable two-factor authentication for your account.",
+)
+async def disable_totp(
+ request: DisableTOTPRequest,
+ current_user_id: str = Depends(get_current_user_id),
+ handler: DisableTOTPHandler = Depends(get_disable_totp_handler),
+):
+ """Disable 2FA.
+
+ Requires either a current TOTP code or a backup code to verify identity.
+ """
+ from uuid import UUID
+
+ command = DisableTOTPCommand(
+ user_id=UUID(current_user_id),
+ code=request.code,
+ )
+ result = await handler.execute(command)
+
+ if result:
+ return SuccessResponse(
+ success=True,
+ message="2FA disabled successfully",
+ data=TwoFactorVerifyResponse(success=True),
+ )
+
+ raise HTTPException(status_code=400, detail="Failed to disable 2FA")
+
+
+@router.post(
+ "/send-email-code",
+ response_model=SuccessResponse[TwoFactorVerifyResponse],
+ summary="Send 2FA code via email",
+ description="Send a verification code to your registered email address.",
+)
+async def send_email_2fa_code(
+ request: SendEmail2FACodeRequest,
+ current_user_id: str = Depends(get_current_user_id),
+ handler: SendEmail2FACodeHandler = Depends(get_send_email_2fa_code_handler),
+):
+ """Send a 2FA verification code via email.
+
+ Alternative to TOTP for users who prefer email-based verification.
+ The code will expire in 10 minutes.
+ """
+ from uuid import UUID
+
+ command = SendEmail2FACodeCommand(user_id=UUID(current_user_id))
+ result = await handler.execute(command)
+
+ if result:
+ return SuccessResponse(
+ success=True,
+ message="Verification code sent to your email",
+ data=TwoFactorVerifyResponse(success=True),
+ )
+
+ raise HTTPException(status_code=500, detail="Failed to send email")
+
+
+@router.post(
+ "/verify-email-code",
+ response_model=SuccessResponse[TwoFactorVerifyResponse],
+ summary="Verify email 2FA code",
+ description="Verify a 2FA code received via email.",
+)
+async def verify_email_2fa_code(
+ request: VerifyEmail2FACodeRequest,
+ current_user_id: str = Depends(get_current_user_id),
+ handler: VerifyEmail2FACodeHandler = Depends(get_verify_email_2fa_code_handler),
+):
+ """Verify an email-based 2FA code."""
+ from uuid import UUID
+
+ command = VerifyEmail2FACodeCommand(
+ user_id=UUID(current_user_id),
+ code=request.code,
+ )
+ result = await handler.execute(command)
+
+ if result:
+ return SuccessResponse(
+ success=True,
+ message="Email verification successful",
+ data=TwoFactorVerifyResponse(success=True),
+ )
+
+ raise HTTPException(status_code=400, detail="Invalid or expired code")
+
+
+@router.post(
+ "/regenerate-backup-codes",
+ response_model=SuccessResponse[TwoFactorEnableResponse],
+ summary="Regenerate backup codes",
+ description="Generate new backup codes for account recovery.",
+)
+async def regenerate_backup_codes(
+ request: RegenerateBackupCodesRequest,
+ current_user_id: str = Depends(get_current_user_id),
+ handler: RegenerateBackupCodesHandler = Depends(
+ get_regenerate_backup_codes_handler
+ ),
+):
+ """Regenerate backup codes.
+
+ This will invalidate all previous backup codes and generate new ones.
+ Requires a current TOTP code for verification.
+ """
+ from uuid import UUID
+
+ command = RegenerateBackupCodesCommand(
+ user_id=UUID(current_user_id),
+ verify_code=request.verify_code,
+ )
+ result = await handler.execute(command)
+
+ return SuccessResponse(
+ success=True,
+ message="New backup codes generated. Store them safely!",
+ data=TwoFactorEnableResponse(**result),
+ )
+
+
+@router.post(
+ "/verify",
+ response_model=SuccessResponse[TwoFactorVerifyResponse],
+ summary="Verify 2FA code",
+ description="Verify a 2FA code (used during login flow).",
+)
+async def verify_2fa(
+ request: Verify2FARequest,
+ current_user_id: str = Depends(get_current_user_id),
+ handler: Verify2FAHandler = Depends(get_verify_2fa_handler),
+):
+ """Verify a 2FA code.
+
+ Used in the login flow when 2FA is required.
+ Supports TOTP, email, and backup code methods.
+ """
+ from uuid import UUID
+
+ command = Verify2FACommand(
+ user_id=UUID(current_user_id),
+ code=request.code,
+ method=request.method,
+ )
+ result = await handler.execute(command)
+
+ if result:
+ return SuccessResponse(
+ success=True,
+ message="2FA verification successful",
+ data=TwoFactorVerifyResponse(success=True),
+ )
+
+ raise HTTPException(status_code=400, detail="Invalid 2FA code")
diff --git a/src/modules/user/presentation/routers/user_router.py b/src/modules/user/presentation/routers/user_router.py
index c7fe64a..b52f928 100644
--- a/src/modules/user/presentation/routers/user_router.py
+++ b/src/modules/user/presentation/routers/user_router.py
@@ -1,27 +1,29 @@
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordRequestForm
-from src.core.authorization.dependencies import require_permission
-from src.core.authorization.permissions import (
+from src.core.schemas.response import SuccessResponse
+from src.modules.authorization.domain.permissions import (
ME_ACTION,
UPDATE_ACTION,
USER_RESOURCE,
)
-from src.core.schemas.response import SuccessResponse
-from src.modules.user.application.detail_user.handler import DetailUserQueryHandler
-from src.modules.user.application.detail_user.query import DetailUserQuery
-from src.modules.user.application.login_user.command import LoginUserCommand
-from src.modules.user.application.login_user.handler import LoginUserCommandHandler
-from src.modules.user.application.logout_user.command import LogoutUserCommand
-from src.modules.user.application.logout_user.handler import LogoutUserCommandHandler
-from src.modules.user.application.refresh_token.command import RefreshTokenCommand
-from src.modules.user.application.refresh_token.handler import (
+from src.modules.authorization.presentation.dependency import require_permission
+from src.modules.user.application.auth.login_user.command import LoginUserCommand
+from src.modules.user.application.auth.login_user.handler import LoginUserCommandHandler
+from src.modules.user.application.auth.logout_user.command import LogoutUserCommand
+from src.modules.user.application.auth.logout_user.handler import (
+ LogoutUserCommandHandler,
+)
+from src.modules.user.application.auth.refresh_token.command import RefreshTokenCommand
+from src.modules.user.application.auth.refresh_token.handler import (
RefreshTokenCommandHandler,
)
-from src.modules.user.application.register_user.command import RegisterUserCommand
-from src.modules.user.application.register_user.handler import (
+from src.modules.user.application.auth.register_user.command import RegisterUserCommand
+from src.modules.user.application.auth.register_user.handler import (
RegisterUserCommandHandler,
)
+from src.modules.user.application.detail_user.handler import DetailUserQueryHandler
+from src.modules.user.application.detail_user.query import DetailUserQuery
from src.modules.user.domain.exceptions.user_exception import UserAlreadyExistsError
from src.modules.user.presentation.dependency import (
get_login_handler,
@@ -66,25 +68,21 @@ async def register(
raise HTTPException(status_code=400, detail=str(e))
-@router.post("/login", response_model=SuccessResponse[TokenResponse])
+@router.post("/login", response_model=TokenResponse)
async def login(
form: OAuth2PasswordRequestForm = Depends(),
handler: LoginUserCommandHandler = Depends(get_login_handler),
):
command = LoginUserCommand(username=form.username, password=form.password)
result = await handler.execute(command=command)
- return SuccessResponse(
- message="Login success",
- success=True,
- data=TokenResponse(
- access_token=result.get("access_token"),
- refresh_token=result.get("refresh_token"),
- token_type="bearer",
- ),
+ return TokenResponse(
+ access_token=result.get("access_token"),
+ refresh_token=result.get("refresh_token"),
+ token_type="bearer",
)
-@router.post("/refresh", response_model=SuccessResponse[TokenResponse])
+@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(
request: RefreshTokenRequest,
handler: RefreshTokenCommandHandler = Depends(get_refresh_token_handler),
@@ -95,14 +93,10 @@ async def refresh_token(
token=request.refresh_token,
)
)
- return SuccessResponse(
- message="Refresh token success",
- success=True,
- data=TokenResponse(
- access_token=result.get("access_token"),
- refresh_token=result.get("refresh_token"),
- token_type="bearer",
- ),
+ return TokenResponse(
+ access_token=result.get("access_token"),
+ refresh_token=result.get("refresh_token"),
+ token_type="bearer",
)
except InvalidRefreshTokenError as e:
raise HTTPException(status_code=401, detail=str(e))
@@ -124,6 +118,15 @@ async def get_me(
data=UserResponse(
id=str(user.id),
email=user.email,
+ username=user.username,
+ auth_provider=user.auth_provider,
+ external_id=user.external_id,
+ status=user.status,
+ profile=user.profile,
+ settings=user.settings,
+ security=user.security,
+ created_at=user.created_at,
+ updated_at=user.updated_at,
),
)
diff --git a/src/modules/user/presentation/schemas/response.py b/src/modules/user/presentation/schemas/response.py
index 9b167f7..abd7ac6 100644
--- a/src/modules/user/presentation/schemas/response.py
+++ b/src/modules/user/presentation/schemas/response.py
@@ -1,5 +1,11 @@
from pydantic import BaseModel
+from src.modules.user.domain.entities.user import (
+ UserProfile,
+ UserSecurity,
+ UserSettings,
+)
+
class TokenResponse(BaseModel):
access_token: str
@@ -10,3 +16,12 @@ class TokenResponse(BaseModel):
class UserResponse(BaseModel):
id: str
email: str
+ username: str | None
+ auth_provider: str = "local"
+ external_id: str | None
+ status: str = "pending_verification"
+ created_at: str | None
+ updated_at: str | None
+ profile: UserProfile | None
+ settings: UserSettings | None
+ security: UserSecurity | None
diff --git a/src/modules/user/presentation/schemas/two_factor.py b/src/modules/user/presentation/schemas/two_factor.py
new file mode 100644
index 0000000..617e403
--- /dev/null
+++ b/src/modules/user/presentation/schemas/two_factor.py
@@ -0,0 +1,69 @@
+"""Request schemas for two-factor authentication endpoints."""
+
+from pydantic import BaseModel, Field
+from typing import Literal
+
+
+class SetupTOTPRequest(BaseModel):
+ """Request to set up TOTP 2FA."""
+ pass
+
+
+class VerifyTOTPSetupRequest(BaseModel):
+ """Request to verify and enable TOTP 2FA."""
+ code: str = Field(..., description="6-digit TOTP code from authenticator app")
+
+
+class DisableTOTPRequest(BaseModel):
+ """Request to disable TOTP 2FA."""
+ code: str = Field(..., description="Current TOTP code or backup code")
+
+
+class SendEmail2FACodeRequest(BaseModel):
+ """Request to send a 2FA code via email."""
+ pass
+
+
+class VerifyEmail2FACodeRequest(BaseModel):
+ """Request to verify an email-based 2FA code."""
+ code: str = Field(..., description="6-digit verification code from email")
+
+
+class RegenerateBackupCodesRequest(BaseModel):
+ """Request to regenerate backup codes."""
+ verify_code: str = Field(..., description="Current TOTP code for verification")
+
+
+class Verify2FARequest(BaseModel):
+ """Request to verify a 2FA code during login."""
+ code: str = Field(..., description="2FA verification code")
+ method: Literal["totp", "email", "backup"] = Field(
+ default="totp",
+ description="Verification method",
+ )
+
+
+class TwoFactorSetupResponse(BaseModel):
+ """Response containing TOTP setup information."""
+ secret: str
+ uri: str
+ qr_code_data: str
+
+
+class TwoFactorEnableResponse(BaseModel):
+ """Response containing backup codes after enabling 2FA."""
+ backup_codes: list[str]
+
+
+class TwoFactorVerifyResponse(BaseModel):
+ """Response for 2FA verification."""
+ success: bool
+ message: str | None = None
+
+
+class LoginWith2FAResponse(BaseModel):
+ """Response when 2FA is required during login."""
+ access_token: str
+ refresh_token: str
+ token_type: str = "bearer"
+ two_factor_required: bool = True
diff --git a/src/modules/user/providers.py b/src/modules/user/providers.py
new file mode 100644
index 0000000..60afd8d
--- /dev/null
+++ b/src/modules/user/providers.py
@@ -0,0 +1,31 @@
+from pydantic import BaseModel
+
+from src.modules.user.application.detail_user.handler import DetailUserQueryHandler
+from src.modules.user.application.detail_user.query import DetailUserQuery
+from src.modules.user.domain.repositories.user_repository import UserRepository
+
+
+class UserProfile(BaseModel):
+ id: str
+ email: str
+ username: str | None = None
+
+
+class UserModuleProvider:
+ def __init__(self, user_repository: UserRepository):
+ self._user_detail_query = DetailUserQueryHandler(
+ user_repository=user_repository
+ )
+
+ async def get_user_profile(self, user_id: str) -> UserProfile | None:
+ user = await self._user_detail_query.execute(
+ DetailUserQuery(user_id=str(user_id))
+ )
+ if user is None:
+ return None
+
+ return UserProfile(
+ id=str(user.id),
+ email=user.email,
+ username=user.username,
+ )
diff --git a/src/shared/database/model.py b/src/shared/database/model.py
index cc37bd0..b9135e9 100644
--- a/src/shared/database/model.py
+++ b/src/shared/database/model.py
@@ -1,10 +1,12 @@
from uuid import UUID, uuid4
+from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
class Base(DeclarativeBase):
id: Mapped[UUID] = mapped_column(
+ PG_UUID(as_uuid=True),
primary_key=True,
default=uuid4,
sort_order=-100,
diff --git a/src/shared/email/base.py b/src/shared/email/base.py
new file mode 100644
index 0000000..1fa30fe
--- /dev/null
+++ b/src/shared/email/base.py
@@ -0,0 +1,15 @@
+from typing import Optional
+
+from pydantic import BaseModel, EmailStr
+
+
+class EmailMessage(BaseModel):
+ """Standard email message format"""
+
+ to: EmailStr
+ subject: str
+ html_body: str
+ text_body: Optional[str] = None
+ from_email: Optional[EmailStr] = None
+ from_name: Optional[str] = None
+ reply_to: Optional[EmailStr] = None
diff --git a/src/shared/events/base.py b/src/shared/events/base.py
new file mode 100644
index 0000000..dbfc925
--- /dev/null
+++ b/src/shared/events/base.py
@@ -0,0 +1,16 @@
+from dataclasses import dataclass, field
+from datetime import UTC, datetime
+from uuid import UUID, uuid4
+
+
+@dataclass(kw_only=True)
+class Event:
+ """Base class for all domain events"""
+
+ event_id: UUID = field(default_factory=uuid4)
+ timestamp: datetime = field(default_factory=lambda: datetime.now(UTC))
+ event_type: str = ""
+
+ def __post_init__(self):
+ if not self.event_type:
+ self.event_type = self.__class__.__name__
diff --git a/src/shared/events/handler.py b/src/shared/events/handler.py
new file mode 100644
index 0000000..acf2dd8
--- /dev/null
+++ b/src/shared/events/handler.py
@@ -0,0 +1,10 @@
+from typing import Protocol
+
+from src.shared.events.base import Event
+
+
+class EventHandler(Protocol):
+ """Protocol for event handlers"""
+
+ async def handle(self, event: Event) -> None:
+ pass
diff --git a/src/core/utils/cursor.py b/src/shared/utils/cursor.py
similarity index 100%
rename from src/core/utils/cursor.py
rename to src/shared/utils/cursor.py
diff --git a/templates/emails/password_reset.html b/templates/emails/password_reset.html
new file mode 100644
index 0000000..e6b7b91
--- /dev/null
+++ b/templates/emails/password_reset.html
@@ -0,0 +1,35 @@
+
+
+
+
+ Reset Your Password
+
+
+
+
Password Reset Request
+
+
Hello {{ user_email }},
+
+
We received a request to reset your password. Click the button below to create a new password:
+
+
+
+
Or copy and paste this link into your browser:
+
{{ reset_url }}
+
+
+ This link will expire in 1 hour. If you didn't request a password reset, please ignore this email.
+
+
+
+
+ © 2024 Todo Modulith. All rights reserved.
+
+
+
+
\ No newline at end of file
diff --git a/templates/emails/verification.html b/templates/emails/verification.html
new file mode 100644
index 0000000..3b7a17c
--- /dev/null
+++ b/templates/emails/verification.html
@@ -0,0 +1,35 @@
+
+
+
+
+ Verify Your Email
+
+
+
+
Welcome to Todo Modulith!
+
+
Hello {{ user_email }},
+
+
Thank you for registering. Please verify your email address by clicking the button below:
+
+
+
+
Or copy and paste this link into your browser:
+
{{ verification_url }}
+
+
+ This link will expire in 24 hours. If you didn't create an account, please ignore this email.
+
+
+
+
+ © 2024 Todo Modulith. All rights reserved.
+
+
+
+
\ No newline at end of file
diff --git a/templates/emails/welcome.html b/templates/emails/welcome.html
new file mode 100644
index 0000000..4d346b4
--- /dev/null
+++ b/templates/emails/welcome.html
@@ -0,0 +1,35 @@
+
+
+
+
+ Welcome!
+
+
+
+
Welcome aboard, {{ username }}!
+
+
Your email has been verified successfully. You're now ready to start using Todo Modulith.
+
+
What's next?
+
+ - Create your first todo
+ - Explore all features
+ - Invite your team
+
+
+
+
+
If you have any questions, feel free to reach out to our support team.
+
+
+
+ © 2024 Todo Modulith. All rights reserved.
+
+
+
+
\ No newline at end of file
diff --git a/tests/core/test_admin_authorization_hardening.py b/tests/core/test_admin_authorization_hardening.py
deleted file mode 100644
index 7d1d2c9..0000000
--- a/tests/core/test_admin_authorization_hardening.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from src.modules.authorization.presenter.routers.permission_router import (
- router as permission_router,
-)
-from src.modules.authorization.presenter.routers.role_router import router as role_router
-
-
-def test_role_and_permission_management_routes_require_dependencies():
- routes = [
- route
- for route in [*role_router.routes, *permission_router.routes]
- if hasattr(route, "dependencies")
- ]
-
- assert routes
- for route in routes:
- assert route.dependencies, f"{route.path} has no authorization dependency"
diff --git a/tests/core/test_api_v1_router.py b/tests/core/test_api_v1_router.py
deleted file mode 100644
index 0fa7cdd..0000000
--- a/tests/core/test_api_v1_router.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from fastapi import FastAPI
-
-from src.core.dependency.auth import oauth2_scheme
-from src.core.middleware.auth import PUBLIC_PATHS
-from src.core.routers.api.v1 import register_router
-
-
-def test_register_router_groups_module_routes_under_api_v1():
- app = FastAPI()
-
- register_router(app)
-
- included_router = app.routes[-1]
- route_paths = {
- route.path for route in included_router.effective_route_contexts()
- }
- assert "/api/v1/auth/login" in route_paths
- assert "/api/v1/auth/register" in route_paths
- assert "/api/v1/auth/refresh" in route_paths
- assert "/api/v1/todos/" in route_paths
- assert "/api/v1/roles/" in route_paths
- assert "/api/v1/roles/{role_id}" in route_paths
- assert "/api/v1/roles/{role_id}/permissions/{permission_id}" in route_paths
- assert "/api/v1/permissions/" in route_paths
- assert "/api/v1/permissions/{permission_id}" in route_paths
- assert "/auth/login" not in route_paths
- assert "/todos/" not in route_paths
-
-
-def test_auth_entrypoints_under_api_v1_are_public():
- assert "/api/v1/auth/login" in PUBLIC_PATHS
- assert "/api/v1/auth/register" in PUBLIC_PATHS
- assert "/api/v1/auth/refresh" in PUBLIC_PATHS
- assert "/auth/login" not in PUBLIC_PATHS
- assert "/auth/register" not in PUBLIC_PATHS
- assert "/auth/refresh" not in PUBLIC_PATHS
-
-
-def test_oauth2_password_flow_points_to_api_v1_auth_entrypoints():
- assert oauth2_scheme.model.flows.password.tokenUrl == "/api/v1/auth/login"
- assert oauth2_scheme.model.flows.password.refreshUrl == "/api/v1/auth/refresh"
diff --git a/tests/core/test_auth_middleware.py b/tests/core/test_auth_middleware.py
deleted file mode 100644
index 6764b79..0000000
--- a/tests/core/test_auth_middleware.py
+++ /dev/null
@@ -1,83 +0,0 @@
-import asyncio
-
-from src.core.middleware.auth import AuthenticationMiddleware
-from src.core.security.jwt import JWTService
-from src.core.security.token_revocation import TokenRevocationService
-from starlette.requests import Request
-from starlette.responses import JSONResponse
-
-
-def test_authentication_middleware_rejects_revoked_access_token(monkeypatch):
- async def run():
- async def revoked_token(_token):
- return True
-
- monkeypatch.setattr(
- TokenRevocationService,
- "is_access_token_revoked",
- revoked_token,
- )
-
- token = JWTService.create_access_token({"sub": "user-id"})
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/protected",
- "headers": [(b"authorization", f"Bearer {token}".encode())],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
-
- async def call_next(_request):
- return JSONResponse({"ok": True})
-
- response = await AuthenticationMiddleware(None).dispatch(request, call_next)
-
- assert response.status_code == 401
-
- asyncio.run(run())
-
-
-def test_authentication_middleware_rejects_refresh_token_on_protected_endpoint(
- monkeypatch,
-):
- async def run():
- async def active_token(_token):
- return False
-
- monkeypatch.setattr(
- TokenRevocationService,
- "is_access_token_revoked",
- active_token,
- )
-
- token = JWTService.create_refresh_token({"sub": "user-id"})
- call_next_called = False
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/protected",
- "headers": [(b"authorization", f"Bearer {token}".encode())],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
-
- async def call_next(_request):
- nonlocal call_next_called
- call_next_called = True
- return JSONResponse({"ok": True})
-
- response = await AuthenticationMiddleware(None).dispatch(request, call_next)
-
- assert response.status_code == 401
- assert call_next_called is False
-
- asyncio.run(run())
diff --git a/tests/core/test_authorization.py b/tests/core/test_authorization.py
deleted file mode 100644
index c7e3aa1..0000000
--- a/tests/core/test_authorization.py
+++ /dev/null
@@ -1,52 +0,0 @@
-import asyncio
-from uuid import uuid4
-
-import pytest
-from fastapi import HTTPException
-
-from src.core.authorization.dependencies import require_permission
-
-
-class FakeAuthorizationService:
- def __init__(self, allowed: bool):
- self.allowed = allowed
- self.calls = []
-
- async def can(self, subject: str, resource: str, action: str) -> bool:
- self.calls.append((subject, resource, action))
- return self.allowed
-
-
-def test_require_permission_returns_current_user_when_allowed():
- async def run():
- user_id = uuid4()
- service = FakeAuthorizationService(allowed=True)
- dependency = require_permission("todo", "create")
-
- current_user = await dependency(
- current_user={"id": user_id},
- authorization_service=service,
- )
-
- assert current_user == {"id": user_id}
- assert service.calls == [(str(user_id), "todo", "create")]
-
- asyncio.run(run())
-
-
-def test_require_permission_rejects_forbidden_user():
- async def run():
- user_id = uuid4()
- service = FakeAuthorizationService(allowed=False)
- dependency = require_permission("todo", "delete")
-
- with pytest.raises(HTTPException) as exc_info:
- await dependency(
- current_user={"id": user_id},
- authorization_service=service,
- )
-
- assert exc_info.value.status_code == 403
- assert service.calls == [(str(user_id), "todo", "delete")]
-
- asyncio.run(run())
diff --git a/tests/core/test_authorization_cursor_pagination.py b/tests/core/test_authorization_cursor_pagination.py
deleted file mode 100644
index fbace7a..0000000
--- a/tests/core/test_authorization_cursor_pagination.py
+++ /dev/null
@@ -1,112 +0,0 @@
-import asyncio
-from datetime import UTC, datetime, timedelta
-from uuid import uuid4
-
-from src.core.utils.cursor import CursorDirection, decode_cursor, encode_cursor
-from src.modules.authorization.domain.entities.permission import Permission
-from src.modules.authorization.domain.entities.role import Role
-from src.modules.authorization.presenter.routers.permission_router import (
- list_permissions,
-)
-from src.modules.authorization.presenter.routers.role_router import list_roles
-
-
-class FakeAuthorizationService:
- def __init__(self, roles=None, permissions=None):
- self.roles = roles or []
- self.permissions = permissions or []
-
- async def list_roles_cursor(
- self,
- cursor_created_at=None,
- cursor_id=None,
- limit=10,
- direction=None,
- ):
- return self.roles[:limit], len(self.roles) > limit
-
- async def list_permissions_cursor(
- self,
- cursor_created_at=None,
- cursor_id=None,
- limit=10,
- direction=None,
- ):
- return self.permissions[:limit], len(self.permissions) > limit
-
-
-def test_list_roles_uses_cursor_paginated_response():
- async def run():
- now = datetime.now(UTC)
- roles = [
- Role(
- id=uuid4(),
- name=f"role-{index}",
- description=None,
- created_at=(now - timedelta(minutes=index)).isoformat(),
- updated_at=(now - timedelta(minutes=index)).isoformat(),
- )
- for index in range(3)
- ]
-
- response = await list_roles(
- cursor=None,
- limit=2,
- service=FakeAuthorizationService(roles),
- )
-
- assert response.data[0].id == str(roles[0].id)
- assert len(response.data) == 2
- assert response.meta.limit == 2
- assert response.meta.has_next is True
- assert response.meta.has_prev is False
-
- cursor_created_at, cursor_id, direction = decode_cursor(
- response.meta.next_cursor
- )
- assert cursor_created_at == datetime.fromisoformat(roles[1].created_at)
- assert cursor_id == roles[1].id
- assert direction == CursorDirection.DIRECTION_NEXT
-
- asyncio.run(run())
-
-
-def test_list_permissions_exposes_previous_cursor_when_cursor_is_provided():
- async def run():
- now = datetime.now(UTC)
- permissions = [
- Permission(
- id=uuid4(),
- key=f"todo:action-{index}",
- resource="todo",
- action=f"action-{index}",
- description=None,
- created_at=(now - timedelta(minutes=index)).isoformat(),
- updated_at=(now - timedelta(minutes=index)).isoformat(),
- )
- for index in range(2)
- ]
- cursor = encode_cursor(
- datetime.fromisoformat(permissions[0].created_at),
- permissions[0].id,
- CursorDirection.DIRECTION_NEXT,
- )
-
- response = await list_permissions(
- cursor=cursor,
- limit=2,
- service=FakeAuthorizationService(permissions=permissions),
- )
-
- assert len(response.data) == 2
- assert response.meta.has_next is False
- assert response.meta.has_prev is True
-
- cursor_created_at, cursor_id, direction = decode_cursor(
- response.meta.prev_cursor
- )
- assert cursor_created_at == datetime.fromisoformat(permissions[0].created_at)
- assert cursor_id == permissions[0].id
- assert direction == CursorDirection.DIRECTION_PREV
-
- asyncio.run(run())
diff --git a/tests/core/test_authorization_models.py b/tests/core/test_authorization_models.py
deleted file mode 100644
index 7944a90..0000000
--- a/tests/core/test_authorization_models.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from src.core.authorization.infrastructure.models.casbin_rule_model import (
- CasbinRuleModel,
-)
-from src.core.authorization.infrastructure.models.permission_model import (
- PermissionModel,
-)
-from src.core.authorization.infrastructure.models.resource_model import (
- AuthorizationResourceModel,
-)
-from src.core.authorization.infrastructure.models.role_model import (
- RoleModel,
-)
-from src.core.authorization.infrastructure.models.role_permission_model import (
- RolePermissionModel,
-)
-from src.core.authorization.infrastructure.models.user_has_role_model import (
- UserHasRoleModel,
-)
-
-
-def test_authorization_tables_are_registered_in_metadata():
- assert AuthorizationResourceModel.__tablename__ == "authorization_resources"
- assert RoleModel.__tablename__ == "roles"
- assert PermissionModel.__tablename__ == "permissions"
- assert RolePermissionModel.__tablename__ == "role_permissions"
- assert UserHasRoleModel.__tablename__ == "user_has_roles"
- assert CasbinRuleModel.__tablename__ == "casbin_rules"
-
-
-def test_authorization_models_have_expected_columns():
- assert {"key", "name", "description"}.issubset(
- AuthorizationResourceModel.__table__.columns.keys()
- )
- assert {"name", "description"}.issubset(RoleModel.__table__.columns.keys())
- assert "descpription" not in RoleModel.__table__.columns.keys()
- assert {"key", "resource_id", "resource", "action", "description"}.issubset(
- PermissionModel.__table__.columns.keys()
- )
- assert "descpription" not in PermissionModel.__table__.columns.keys()
- assert {"role_id", "permission_id"}.issubset(
- RolePermissionModel.__table__.columns.keys()
- )
- assert {"user_id", "role_id"}.issubset(UserHasRoleModel.__table__.columns.keys())
diff --git a/tests/core/test_casbin_authorization_service.py b/tests/core/test_casbin_authorization_service.py
deleted file mode 100644
index 550baf5..0000000
--- a/tests/core/test_casbin_authorization_service.py
+++ /dev/null
@@ -1,150 +0,0 @@
-import asyncio
-from uuid import uuid4
-
-from src.core.authorization.infrastructure.services.casbin_authorization_service import (
- CasbinAuthorizationService,
-)
-from src.core.authorization.permissions import DEFAULT_POLICIES
-from src.modules.authorization.domain.entities.permission import Permission
-from src.modules.authorization.domain.entities.role import Role
-
-
-class FakePolicyRepository:
- def __init__(self):
- self.policies = [", ".join(policy) for policy in DEFAULT_POLICIES]
- self.user_roles = {}
- self.roles = {}
- self.permissions = {}
- self.assigned_permissions = []
-
- async def load_policy_lines(self) -> list[str]:
- return self.policies
-
- async def add_policy(self, ptype: str, *values: str) -> None:
- policy = ", ".join([ptype, *values])
- if policy not in self.policies:
- self.policies.append(policy)
-
- async def assign_role(self, subject: str, role: str) -> None:
- self.user_roles.setdefault(subject, [])
- if role not in self.user_roles[subject]:
- self.user_roles[subject].append(role)
- await self.add_policy("g", subject, role)
-
- async def get_roles_for_subject(self, subject: str) -> list[str]:
- return self.user_roles.get(subject, [])
-
- async def create_role(self, role: Role) -> Role:
- self.roles[role.id] = role
- return role
-
- async def get_role(self, role_id):
- return self.roles.get(role_id)
-
- async def list_roles(self):
- return list(self.roles.values())
-
- async def update_role(self, role: Role):
- self.roles[role.id] = role
- return role
-
- async def delete_role(self, role_id) -> None:
- self.roles.pop(role_id, None)
-
- async def create_permission(self, permission: Permission) -> Permission:
- self.permissions[permission.id] = permission
- return permission
-
- async def get_permission(self, permission_id):
- return self.permissions.get(permission_id)
-
- async def list_permissions(self):
- return list(self.permissions.values())
-
- async def update_permission(self, permission: Permission):
- self.permissions[permission.id] = permission
- return permission
-
- async def delete_permission(self, permission_id) -> None:
- self.permissions.pop(permission_id, None)
-
- async def assign_permission_to_role(self, role_id, permission_id) -> None:
- self.assigned_permissions.append((role_id, permission_id))
- role = self.roles[role_id]
- permission = self.permissions[permission_id]
- await self.add_policy("p", role.name, permission.key)
-
- async def remove_permission_from_role(self, role_id, permission_id) -> None:
- self.assigned_permissions.remove((role_id, permission_id))
-
-
-def test_casbin_authorization_service_enforces_database_policies():
- async def run():
- repository = FakePolicyRepository()
- service = CasbinAuthorizationService(repository)
-
- assert await service.can("user-1", "todo", "create") is False
-
- await service.assign_role("user-1", "user")
-
- assert await service.can("user-1", "todo", "create") is True
- assert await service.can("user-1", "admin", "delete") is False
- assert await service.get_roles_for_subject("user-1") == ["user"]
-
- asyncio.run(run())
-
-
-def test_default_policies_use_normalized_permission_keys():
- policy_lines = [", ".join(policy) for policy in DEFAULT_POLICIES]
-
- assert "p, user, todo:create" in policy_lines
- assert "p, user, user:me" in policy_lines
- assert "p, user, todo, create" not in policy_lines
-
-
-def test_authorization_service_manages_roles_permissions_and_assignments():
- async def run():
- repository = FakePolicyRepository()
- service = CasbinAuthorizationService(repository)
- role = Role(id=uuid4(), name="manager", description="Manager")
- permission = Permission(
- id=uuid4(),
- key="todo:review",
- resource="todo",
- action="review",
- description="Review todos",
- )
-
- await service.create_role(role)
- await service.create_permission(permission)
- await service.assign_permission_to_role(role.id, permission.id)
-
- assert await service.get_role(role.id) == role
- assert await service.list_roles() == [role]
- assert await service.get_permission(permission.id) == permission
- assert await service.list_permissions() == [permission]
- assert await service.can("user-1", "todo", "review") is False
-
- await service.assign_role("user-1", "manager")
-
- assert await service.can("user-1", "todo", "review") is True
-
- updated_role = Role(id=role.id, name="manager", description="Updated")
- updated_permission = Permission(
- id=permission.id,
- key="todo:approve",
- resource="todo",
- action="approve",
- description="Approve todos",
- )
- assert await service.update_role(updated_role) == updated_role
- assert await service.update_permission(updated_permission) == updated_permission
-
- await service.remove_permission_from_role(role.id, permission.id)
- await service.delete_permission(permission.id)
- await service.delete_role(role.id)
-
- assert await service.get_role(role.id) is None
- assert await service.get_permission(permission.id) is None
-
- asyncio.run(run())
diff --git a/tests/core/test_database_migration_polish.py b/tests/core/test_database_migration_polish.py
deleted file mode 100644
index c175f3e..0000000
--- a/tests/core/test_database_migration_polish.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from pathlib import Path
-
-
-def test_alembic_env_does_not_emit_debug_prints():
- env_content = Path("alembic/env.py").read_text()
-
- assert "print(" not in env_content
- assert "ALEMBIC DEBUG" not in env_content
-
-
-def test_authorization_description_typo_is_migrated_forward():
- migration_content = Path(
- "alembic/versions/c7a1b9e5d4f2_rename_authorization_description_columns.py"
- ).read_text()
-
- assert "descpription" in migration_content
- assert "description" in migration_content
- assert "rename_column" in migration_content
-
-
-def test_authorization_resources_are_migrated_forward():
- migration_content = Path(
- "alembic/versions/d9a7c3f2b6e1_add_authorization_resources.py"
- ).read_text()
-
- assert "authorization_resources" in migration_content
- assert "resource_id" in migration_content
- assert "permissions" in migration_content
diff --git a/tests/core/test_global_audit_logging.py b/tests/core/test_global_audit_logging.py
deleted file mode 100644
index 4ac6162..0000000
--- a/tests/core/test_global_audit_logging.py
+++ /dev/null
@@ -1,117 +0,0 @@
-import asyncio
-
-import pytest
-from starlette.requests import Request
-from starlette.responses import JSONResponse
-
-from src.core.middleware.audit_logging import AuditLoggingMiddleware
-
-
-class FakeAuditService:
- def __init__(self):
- self.events = []
-
- async def record(self, event):
- self.events.append(event)
-
-
-class FakeErrorTraceService:
- def __init__(self):
- self.traces = []
-
- async def record(self, trace):
- self.traces.append(trace)
-
-
-def build_request(path="/api/v1/todos/123", method="PATCH"):
- request = Request(
- {
- "type": "http",
- "method": method,
- "path": path,
- "headers": [
- (b"user-agent", b"pytest"),
- (b"x-forwarded-for", b"10.0.0.1"),
- ],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- "path_params": {"todo_id": "123"},
- }
- )
- request.state.request_id = "request-1"
- request.state.user_id = "user-1"
- return request
-
-
-def test_global_audit_logging_records_api_request():
- async def run():
- audit_service = FakeAuditService()
- middleware = AuditLoggingMiddleware(
- None,
- audit_service_factory=lambda: audit_service,
- )
-
- async def call_next(_request):
- return JSONResponse({"ok": True}, status_code=202)
-
- response = await middleware.dispatch(build_request(), call_next)
-
- assert response.status_code == 202
- event = audit_service.events[0]
- assert event.action == "PATCH /api/v1/todos/123"
- assert event.actor_id == "user-1"
- assert event.resource_type == "todos"
- assert event.resource_id == "123"
- assert event.request_id == "request-1"
- assert event.metadata["status_code"] == 202
- assert event.metadata["client_ip"] == "10.0.0.1"
- assert event.metadata["user_agent"] == "pytest"
-
- asyncio.run(run())
-
-
-def test_global_audit_logging_skips_operational_paths():
- async def run():
- audit_service = FakeAuditService()
- middleware = AuditLoggingMiddleware(
- None,
- audit_service_factory=lambda: audit_service,
- )
-
- async def call_next(_request):
- return JSONResponse({"status": "healthy"})
-
- await middleware.dispatch(build_request(path="/health", method="GET"), call_next)
-
- assert audit_service.events == []
-
- asyncio.run(run())
-
-
-def test_global_audit_logging_records_error_trace_and_reraises():
- async def run():
- audit_service = FakeAuditService()
- error_trace_service = FakeErrorTraceService()
- middleware = AuditLoggingMiddleware(
- None,
- audit_service_factory=lambda: audit_service,
- error_trace_service_factory=lambda: error_trace_service,
- )
-
- async def call_next(_request):
- raise RuntimeError("database unavailable")
-
- with pytest.raises(RuntimeError, match="database unavailable"):
- await middleware.dispatch(build_request(), call_next)
-
- trace = error_trace_service.traces[0]
- assert trace.error_type == "RuntimeError"
- assert trace.message == "database unavailable"
- assert "RuntimeError: database unavailable" in trace.traceback
- assert trace.request_id == "request-1"
- assert trace.actor_id == "user-1"
- assert trace.path == "/api/v1/todos/123"
-
- asyncio.run(run())
diff --git a/tests/core/test_jwt_claims.py b/tests/core/test_jwt_claims.py
deleted file mode 100644
index 308a46d..0000000
--- a/tests/core/test_jwt_claims.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from datetime import datetime, timezone
-
-from src.core.security.jwt import JWTService
-
-
-def test_access_token_uses_standard_claim_payload():
- token = JWTService.create_access_token({"sub": "user-id"})
-
- claims = JWTService.decode_token(token)
-
- assert claims["sub"] == "user-id"
- assert claims["iss"]
- assert claims["aud"]
- assert claims["token_type"] == "access"
- assert claims["jti"]
- assert claims["iat"]
- assert claims["nbf"]
- assert claims["exp"]
- assert claims["nbf"] <= claims["iat"]
- assert claims["iat"] <= claims["exp"]
-
-
-def test_refresh_token_uses_standard_claim_payload():
- token = JWTService.create_refresh_token({"sub": "user-id"})
-
- claims = JWTService.decode_token(token)
-
- assert claims["sub"] == "user-id"
- assert claims["iss"]
- assert claims["aud"]
- assert claims["token_type"] == "refresh"
- assert claims["jti"]
- assert claims["iat"]
- assert claims["nbf"]
- assert claims["exp"]
- assert claims["nbf"] <= claims["iat"]
- assert claims["iat"] <= claims["exp"]
-
-
-def test_token_claim_expiry_is_timezone_aware_epoch_timestamp():
- token = JWTService.create_access_token({"sub": "user-id"})
-
- claims = JWTService.decode_token(token)
- expires_at = datetime.fromtimestamp(claims["exp"], tz=timezone.utc)
- issued_at = datetime.fromtimestamp(claims["iat"], tz=timezone.utc)
-
- assert expires_at.tzinfo == timezone.utc
- assert issued_at.tzinfo == timezone.utc
diff --git a/tests/core/test_makefile.py b/tests/core/test_makefile.py
deleted file mode 100644
index c2147cc..0000000
--- a/tests/core/test_makefile.py
+++ /dev/null
@@ -1,14 +0,0 @@
-import subprocess
-
-
-def test_make_help_uses_target_prefixes():
- result = subprocess.run(
- ["make", "help"],
- check=True,
- capture_output=True,
- text=True,
- )
-
- assert "[make:help]" in result.stdout
- assert "[make:test]" in result.stdout
- assert "[make:check]" in result.stdout
diff --git a/tests/core/test_refresh_token_settings.py b/tests/core/test_refresh_token_settings.py
deleted file mode 100644
index ad12c2c..0000000
--- a/tests/core/test_refresh_token_settings.py
+++ /dev/null
@@ -1,32 +0,0 @@
-from datetime import datetime, timezone
-
-from jose import jwt
-
-from src.core.config.setting import get_settings
-from src.core.security import jwt as jwt_module
-from src.core.security.jwt import JWTService
-
-settings = get_settings()
-
-
-def test_refresh_token_expiry_setting_is_minutes_based():
- assert settings.REFRESH_TOKEN_EXPIRE_MINUTES
- assert not hasattr(settings, "REFRESH_TOKEN_EXPIRE_DAYS")
-
-
-def test_refresh_token_jwt_expiry_uses_minutes_setting(monkeypatch):
- monkeypatch.setattr(settings, "REFRESH_TOKEN_EXPIRE_MINUTES", 15)
-
- before = datetime.now(timezone.utc)
- token = JWTService.create_refresh_token({"sub": "user-id"})
- after = datetime.now(timezone.utc)
-
- claims = jwt.get_unverified_claims(token)
- expires_at = datetime.fromtimestamp(claims["exp"], tz=timezone.utc)
-
- assert before.timestamp() + (15 * 60) - 1 <= expires_at.timestamp()
- assert expires_at.timestamp() <= after.timestamp() + (15 * 60)
- assert (
- "REFRESH_TOKEN_EXPIRE_DAYS"
- not in jwt_module.JWTService.create_refresh_token.__code__.co_names
- )
diff --git a/tests/core/test_security_not_implemented.py b/tests/core/test_security_not_implemented.py
deleted file mode 100644
index 1e71084..0000000
--- a/tests/core/test_security_not_implemented.py
+++ /dev/null
@@ -1,378 +0,0 @@
-import asyncio
-import json
-import logging
-from datetime import datetime, timezone
-
-import pytest
-from fastapi import FastAPI
-from fastapi.testclient import TestClient
-from starlette.requests import Request
-from starlette.responses import JSONResponse
-
-from src.core.config.setting import Settings
-from src.core.middleware.auth import AuthenticationMiddleware
-from src.core.middleware.idempotency import IdempotencyMiddleware
-from src.core.middleware.request_id import RequestIDMiddleware
-from src.core.middleware.security_headers import SecurityHeadersMiddleware
-from src.core.middleware.structured_logging import StructuredLoggingMiddleware
-from src.core.routers.admin import register_router as register_admin_router
-from src.core.security.account_lockout import AccountLockoutService
-from src.core.security.audit import AuditEvent, AuditService
-
-
-def test_security_headers_middleware_adds_expected_headers():
- async def run():
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/health",
- "headers": [],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
-
- async def call_next(_request):
- return JSONResponse({"ok": True})
-
- response = await SecurityHeadersMiddleware(None).dispatch(request, call_next)
-
- assert response.headers["x-content-type-options"] == "nosniff"
- assert response.headers["x-frame-options"] == "DENY"
- assert response.headers["referrer-policy"] == "no-referrer"
- assert "default-src" in response.headers["content-security-policy"]
-
- asyncio.run(run())
-
-
-def test_request_id_middleware_propagates_existing_request_id():
- async def run():
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/health",
- "headers": [(b"x-request-id", b"request-123")],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
-
- async def call_next(received_request):
- assert received_request.state.request_id == "request-123"
- return JSONResponse({"ok": True})
-
- response = await RequestIDMiddleware(None).dispatch(request, call_next)
-
- assert response.headers["x-request-id"] == "request-123"
-
- asyncio.run(run())
-
-
-def test_structured_logging_middleware_logs_request_context(caplog):
- async def run():
- caplog.set_level(logging.INFO, logger="src.core.middleware.structured_logging")
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/api/v1/todos/",
- "headers": [],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
- request.state.request_id = "request-123"
- request.state.user_id = "user-123"
-
- async def call_next(_request):
- return JSONResponse({"ok": True}, status_code=202)
-
- await StructuredLoggingMiddleware(None).dispatch(request, call_next)
-
- asyncio.run(run())
-
- record = caplog.records[0]
- assert record.method == "GET"
- assert record.path == "/api/v1/todos/"
- assert record.status_code == 202
- assert record.request_id == "request-123"
- assert record.user_id == "user-123"
-
-
-def test_structured_logging_middleware_logs_exception_context(caplog):
- async def run():
- caplog.set_level(logging.ERROR, logger="src.core.middleware.structured_logging")
- request = Request(
- {
- "type": "http",
- "method": "POST",
- "path": "/api/v1/todos/",
- "headers": [],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
- request.state.request_id = "request-456"
- request.state.user_id = "user-456"
-
- async def call_next(_request):
- raise RuntimeError("write failed")
-
- with pytest.raises(RuntimeError, match="write failed"):
- await StructuredLoggingMiddleware(None).dispatch(request, call_next)
-
- asyncio.run(run())
-
- record = caplog.records[0]
- assert record.method == "POST"
- assert record.path == "/api/v1/todos/"
- assert record.status_code == 500
- assert record.request_id == "request-456"
- assert record.user_id == "user-456"
- assert record.error_type == "RuntimeError"
-
-
-def test_structured_logging_formats_record_as_json():
- from src.core.middleware.structured_logging import JsonLogFormatter
-
- record = logging.LogRecord(
- name="test",
- level=logging.INFO,
- pathname=__file__,
- lineno=1,
- msg="request completed",
- args=(),
- exc_info=None,
- )
- record.method = "GET"
- record.path = "/api/v1/todos/"
- record.status_code = 200
- record.latency_ms = 1.5
- record.request_id = "request-1"
- record.user_id = "user-1"
- record.error_type = None
-
- payload = json.loads(JsonLogFormatter().format(record))
-
- assert payload["message"] == "request completed"
- assert payload["method"] == "GET"
- assert payload["path"] == "/api/v1/todos/"
- assert payload["status_code"] == 200
- assert payload["request_id"] == "request-1"
-
-
-def test_admin_router_exposes_liveness_and_readiness():
- app = FastAPI()
- register_admin_router(app)
- client = TestClient(app)
-
- assert client.get("/live").json() == {"status": "alive"}
- ready_response = client.get("/ready")
- assert ready_response.status_code in (200, 503)
- assert "checks" in ready_response.json()
-
-
-def test_authentication_middleware_returns_generic_invalid_token_error():
- async def run():
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/protected",
- "headers": [(b"authorization", b"Bearer invalid-token")],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
-
- async def call_next(_request):
- return JSONResponse({"ok": True})
-
- response = await AuthenticationMiddleware(None).dispatch(request, call_next)
- body = json.loads(response.body.decode())
-
- assert response.status_code == 401
- assert body["detail"] == "Invalid or expired token"
-
- asyncio.run(run())
-
-
-class FakeAuditRepository:
- def __init__(self):
- self.events = []
-
- async def save(self, event):
- self.events.append(event)
- return event
-
-
-def test_audit_service_persists_sensitive_event():
- async def run():
- repo = FakeAuditRepository()
- service = AuditService(repo)
- event = AuditEvent(
- action="user.login",
- actor_id="user-1",
- resource_type="user",
- resource_id="user-1",
- request_id="request-1",
- metadata={"result": "success"},
- )
-
- await service.record(event)
-
- assert repo.events == [event]
-
- asyncio.run(run())
-
-
-class FakeLoginAttemptRepository:
- def __init__(self):
- self.failures = {}
- self.locked_until = {}
- self.cleared = []
-
- async def count_failures_since(self, email, since):
- return self.failures.get(email, 0)
-
- async def record_failure(self, email, occurred_at, locked_until=None):
- self.failures[email] = self.failures.get(email, 0) + 1
- if locked_until is not None:
- self.locked_until[email] = locked_until
-
- async def get_locked_until(self, email):
- return self.locked_until.get(email)
-
- async def clear(self, email):
- self.cleared.append(email)
- self.failures.pop(email, None)
- self.locked_until.pop(email, None)
-
-
-def test_account_lockout_locks_after_configured_failures():
- async def run():
- repo = FakeLoginAttemptRepository()
- settings = Settings(
- ACCOUNT_LOCKOUT_MAX_ATTEMPTS=2,
- ACCOUNT_LOCKOUT_WINDOW_MINUTES=5,
- ACCOUNT_LOCKOUT_DURATION_MINUTES=15,
- )
- service = AccountLockoutService(repo, settings)
-
- await service.record_failed_login("person@example.com")
- await service.record_failed_login("person@example.com")
-
- assert repo.locked_until["person@example.com"] > datetime.now(timezone.utc)
- with pytest.raises(ValueError, match="temporarily locked"):
- await service.ensure_login_allowed("person@example.com")
-
- asyncio.run(run())
-
-
-class FakeRedis:
- def __init__(self):
- self.values = {}
-
- async def get(self, key):
- return self.values.get(key)
-
- async def set(self, key, value, ex=None, nx=False):
- if nx and key in self.values:
- return False
- self.values[key] = value
- return True
-
- async def setex(self, key, ttl, value):
- self.values[key] = value
-
-
-def build_post_request(body: bytes):
- async def receive():
- return {"type": "http.request", "body": body, "more_body": False}
-
- return Request(
- {
- "type": "http",
- "method": "POST",
- "path": "/api/v1/todos/",
- "headers": [
- (b"idempotency-key", b"create-todo-1"),
- (b"authorization", b"Bearer token"),
- (b"content-type", b"application/json"),
- ],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- },
- receive,
- )
-
-
-def test_idempotency_middleware_replays_cached_post_response():
- async def run():
- redis = FakeRedis()
- calls = 0
- async def call_next(_request):
- nonlocal calls
- calls += 1
- return JSONResponse({"created": True}, status_code=201)
-
- middleware = IdempotencyMiddleware(None, redis=redis)
- first = await middleware.dispatch(
- build_post_request(b'{"title":"first"}'),
- call_next,
- )
- second = await middleware.dispatch(
- build_post_request(b'{"title":"first"}'),
- call_next,
- )
-
- assert first.status_code == 201
- assert second.status_code == 201
- assert json.loads(second.body.decode()) == {"created": True}
- assert calls == 1
-
- asyncio.run(run())
-
-
-def test_idempotency_middleware_rejects_same_key_with_different_body():
- async def run():
- redis = FakeRedis()
- calls = 0
-
- async def call_next(_request):
- nonlocal calls
- calls += 1
- return JSONResponse({"created": True}, status_code=201)
-
- middleware = IdempotencyMiddleware(None, redis=redis)
- first = await middleware.dispatch(
- build_post_request(b'{"title":"first"}'),
- call_next,
- )
- second = await middleware.dispatch(
- build_post_request(b'{"title":"second"}'),
- call_next,
- )
-
- assert first.status_code == 201
- assert second.status_code == 409
- assert json.loads(second.body.decode())["detail"] == (
- "Idempotency-Key was already used with a different request body"
- )
- assert calls == 1
-
- asyncio.run(run())
diff --git a/tests/core/test_security_todo.py b/tests/core/test_security_todo.py
deleted file mode 100644
index 3af81ea..0000000
--- a/tests/core/test_security_todo.py
+++ /dev/null
@@ -1,200 +0,0 @@
-import pytest
-from fastapi import FastAPI, Request, Response
-
-from src.core.bootstrap.exception import register_exception
-from src.core.bootstrap.middleware import register_middleware
-from src.core.config.setting import Settings
-from src.core.dependency import rate_limit as rate_limit_module
-from src.core.dependency.rate_limit import apply_global_rate_limit, custom_identifier
-from src.core.exceptions.handler import (
- DOMAIN_EXCEPTION_MAP,
- domain_exception_handler,
- global_exception_handler,
-)
-from src.main import create_app
-
-
-def test_rate_limit_uses_configured_rate_limit_setting(monkeypatch):
- created_limiters = []
-
- class FakeLimiter:
- def __init__(self, times: int, seconds: int):
- self.times = times
- self.seconds = seconds
- created_limiters.append(self)
-
- async def __call__(self, request, response):
- return None
-
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/api/v1/todos/",
- "headers": [],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
-
- monkeypatch.setattr(rate_limit_module.settings, "RATE_LIMIT", "42/minute")
- monkeypatch.setattr(rate_limit_module, "RateLimiter", FakeLimiter)
-
- import asyncio
-
- asyncio.run(apply_global_rate_limit(request, Response()))
-
- assert created_limiters[0].times == 42
- assert created_limiters[0].seconds == 60
-
-
-def test_rate_limit_passes_response_to_limiter(monkeypatch):
- limiter_calls = []
-
- class FakeLimiter:
- def __init__(self, times: int, seconds: int):
- self.times = times
- self.seconds = seconds
-
- async def __call__(self, request, response):
- limiter_calls.append((request, response))
-
- request = Request(
- {
- "type": "http",
- "method": "GET",
- "path": "/api/v1/todos/",
- "headers": [],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
- response = Response()
-
- monkeypatch.setattr(rate_limit_module, "RateLimiter", FakeLimiter)
-
- import asyncio
-
- asyncio.run(apply_global_rate_limit(request, response))
-
- assert limiter_calls == [(request, response)]
-
-
-def test_rate_limit_handles_included_router_entries():
- class FakeRedis:
- async def script_load(self, script):
- return "sha"
-
- async def evalsha(self, sha, keys, key, times, milliseconds):
- return 0
-
- app = create_app(Settings(APP_ENV="development"))
- request = Request(
- {
- "type": "http",
- "app": app,
- "method": "GET",
- "path": "/api/v1/todos/",
- "headers": [],
- "query_string": b"",
- "server": ("testserver", 80),
- "scheme": "http",
- "client": ("testclient", 50000),
- }
- )
-
- import asyncio
- from fastapi_limiter import FastAPILimiter
-
- async def run_rate_limit():
- await FastAPILimiter.init(FakeRedis(), identifier=custom_identifier)
- await apply_global_rate_limit(request, Response())
-
- asyncio.run(run_rate_limit())
-
-
-def test_cors_middleware_uses_environment_driven_settings(monkeypatch):
- monkeypatch.setattr(
- "src.core.bootstrap.middleware.settings",
- Settings(
- CORS_ALLOW_ORIGINS="https://app.example.com,https://admin.example.com",
- CORS_ALLOW_METHODS="GET,POST",
- CORS_ALLOW_HEADERS="Authorization,Content-Type",
- ),
- )
- app = FastAPI()
-
- register_middleware(app)
-
- cors = next(
- middleware
- for middleware in app.user_middleware
- if middleware.cls.__name__ == "CORSMiddleware"
- )
- assert cors.kwargs["allow_origins"] == [
- "https://app.example.com",
- "https://admin.example.com",
- ]
- assert cors.kwargs["allow_methods"] == ["GET", "POST"]
- assert cors.kwargs["allow_headers"] == ["Authorization", "Content-Type"]
-
-
-def test_register_exception_uses_specific_domain_handlers_and_single_fallback():
- app = FastAPI()
-
- register_exception(app)
-
- for exception_type in DOMAIN_EXCEPTION_MAP:
- assert app.exception_handlers[exception_type] is domain_exception_handler
- assert app.exception_handlers[Exception] is global_exception_handler
-
-
-def test_create_app_disables_openapi_entrypoints_in_production():
- app = create_app(Settings(APP_ENV="production", SECRET_KEY="production-secret"))
-
- assert app.docs_url is None
- assert app.redoc_url is None
- assert app.openapi_url is None
-
-
-def test_production_settings_reject_default_secret_key():
- with pytest.raises(ValueError, match="SECRET_KEY must be changed"):
- Settings(
- APP_ENV="production",
- SECRET_KEY=Settings.DEFAULT_SECRET_KEY,
- _env_file=None,
- )
-
-
-def test_production_settings_reject_wildcard_cors():
- with pytest.raises(ValueError, match="CORS_ALLOW_ORIGINS"):
- Settings(
- APP_ENV="production",
- SECRET_KEY="production-secret",
- CORS_ALLOW_ORIGINS="*",
- _env_file=None,
- )
-
-
-def test_production_settings_reject_invalid_token_ttl():
- with pytest.raises(ValueError, match="ACCESS_TOKEN_EXPIRE_MINUTES"):
- Settings(
- APP_ENV="production",
- SECRET_KEY="production-secret",
- ACCESS_TOKEN_EXPIRE_MINUTES=0,
- _env_file=None,
- )
-
-
-def test_production_settings_reject_missing_service_urls():
- with pytest.raises(ValueError, match="DATABASE_URL"):
- Settings(
- APP_ENV="production",
- SECRET_KEY="production-secret",
- DATABASE_URL="",
- _env_file=None,
- )
diff --git a/tests/core/test_seed_cli.py b/tests/core/test_seed_cli.py
deleted file mode 100644
index e5ad192..0000000
--- a/tests/core/test_seed_cli.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from pathlib import Path
-
-from src.core.config.setting import Settings
-
-
-def test_makefile_exposes_seed_command():
- makefile = Path("Makefile").read_text()
-
- assert "seed:" in makefile
- assert "[make:seed]" in makefile
- assert "scripts/seed.py" in makefile
-
-
-def test_seed_script_uses_seed_runner():
- script = Path("scripts/seed.py").read_text()
-
- assert "sys.path.insert" in script
- assert "run_seeders" in script
- assert "seed:user" in script
-
-
-def test_env_example_documents_seed_admin_settings():
- env_example = Path(".env.example").read_text()
-
- assert "SEED_ADMIN_EMAIL=" in env_example
- assert "SEED_ADMIN_PASSWORD=" in env_example
- assert "SEED_ADMIN_USERNAME=admin" in env_example
- assert "SEED_ADMIN_FULLNAME=System Administrator" in env_example
- assert "SEED_DEVELOPMENT_USERS_PASSWORD=" in env_example
-
-
-def test_seed_development_users_password_has_no_default_secret():
- settings = Settings(_env_file=None)
-
- assert settings.SEED_DEVELOPMENT_USERS_PASSWORD == ""
diff --git a/tests/core/test_seed_mechanism.py b/tests/core/test_seed_mechanism.py
deleted file mode 100644
index 424558f..0000000
--- a/tests/core/test_seed_mechanism.py
+++ /dev/null
@@ -1,127 +0,0 @@
-import pytest
-
-from src.core.authorization.permissions import (
- ADMIN_ROLE,
- DEFAULT_RESOURCES,
- DEFAULT_ROLES,
- DEFAULT_POLICIES,
- DEFAULT_USER_ROLE,
- MANAGER_ROLE,
- VIEWER_ROLE,
-)
-from src.core.seed.authorization import seed_authorization
-from src.modules.authorization.domain.entities.permission import Permission
-from src.modules.authorization.domain.entities.resource import AuthorizationResource
-from src.modules.authorization.domain.entities.role import Role
-
-
-class FakePolicyRepository:
- def __init__(self):
- self.resources: dict[str, AuthorizationResource] = {}
- self.roles: dict[str, Role] = {}
- self.permissions: dict[str, Permission] = {}
- self.role_permissions: set[tuple[str, str]] = set()
- self.policies: set[tuple[str, str, str]] = set()
-
- async def list_resources(self) -> list[AuthorizationResource]:
- return list(self.resources.values())
-
- async def create_resource(
- self,
- resource: AuthorizationResource,
- ) -> AuthorizationResource:
- self.resources[resource.key] = resource
- return resource
-
- async def list_roles(self) -> list[Role]:
- return list(self.roles.values())
-
- async def create_role(self, role: Role) -> Role:
- self.roles[role.name] = role
- return role
-
- async def list_permissions(self) -> list[Permission]:
- return list(self.permissions.values())
-
- async def create_permission(self, permission: Permission) -> Permission:
- self.permissions[permission.key] = permission
- return permission
-
- async def list_role_permissions(self) -> list[tuple[str, str]]:
- return list(self.role_permissions)
-
- async def assign_permission_to_role(
- self,
- role_id,
- permission_id,
- ) -> None:
- role = next(role for role in self.roles.values() if role.id == role_id)
- permission = next(
- permission
- for permission in self.permissions.values()
- if permission.id == permission_id
- )
- self.role_permissions.add((role.name, permission.key))
-
- async def add_policy(self, ptype: str, *values: str) -> None:
- self.policies.add((ptype, *values))
-
- async def list_policies(self) -> list[tuple[str, ...]]:
- return list(self.policies)
-
-
-@pytest.mark.anyio
-async def test_seed_authorization_creates_default_roles_permissions_and_policies():
- repository = FakePolicyRepository()
-
- result = await seed_authorization(repository)
-
- assert {resource.key for resource in DEFAULT_RESOURCES}.issubset(
- repository.resources.keys()
- )
- assert {
- ADMIN_ROLE,
- DEFAULT_USER_ROLE,
- MANAGER_ROLE,
- VIEWER_ROLE,
- }.issubset(repository.roles.keys())
- assert {
- policy[2]
- for policy in DEFAULT_POLICIES
- if policy[0] == "p" and policy[2] != "*"
- }.issubset(repository.permissions.keys())
- assert ("p", ADMIN_ROLE, "*") in repository.policies
- assert ("p", DEFAULT_USER_ROLE, "todo:create") in repository.policies
- assert ("p", MANAGER_ROLE, "todo:update") in repository.policies
- assert ("p", VIEWER_ROLE, "todo:read") in repository.policies
- assert (DEFAULT_USER_ROLE, "todo:create") in repository.role_permissions
- assert (MANAGER_ROLE, "todo:update") in repository.role_permissions
- assert (VIEWER_ROLE, "todo:read") in repository.role_permissions
- assert result.resources_created == len(DEFAULT_RESOURCES)
- assert result.roles_created == len(DEFAULT_ROLES)
- assert result.permissions_created == 5
- assert result.role_permissions_created == len(
- [policy for policy in DEFAULT_POLICIES if policy[0] == "p" and policy[2] != "*"]
- )
- assert result.policies_created == len(DEFAULT_POLICIES)
-
-
-@pytest.mark.anyio
-async def test_seed_authorization_is_idempotent():
- repository = FakePolicyRepository()
-
- await seed_authorization(repository)
- result = await seed_authorization(repository)
-
- assert result.roles_created == 0
- assert result.resources_created == 0
- assert result.permissions_created == 0
- assert result.role_permissions_created == 0
- assert result.policies_created == 0
- assert len(repository.resources) == len(DEFAULT_RESOURCES)
- assert len(repository.roles) == len(DEFAULT_ROLES)
- assert len(repository.permissions) == 5
- assert len(repository.role_permissions) == len(
- [policy for policy in DEFAULT_POLICIES if policy[0] == "p" and policy[2] != "*"]
- )
- assert len(repository.policies) == len(DEFAULT_POLICIES)
diff --git a/tests/core/test_token_revocation.py b/tests/core/test_token_revocation.py
deleted file mode 100644
index 7255d23..0000000
--- a/tests/core/test_token_revocation.py
+++ /dev/null
@@ -1,71 +0,0 @@
-import asyncio
-from datetime import datetime, timedelta, timezone
-
-from jose import jwt
-
-from src.core.config.setting import get_settings
-from src.core.security.jwt import JWTService
-from src.core.security.token_revocation import TokenRevocationService
-
-settings = get_settings()
-
-
-class FakeRedis:
- def __init__(self):
- self.values = {}
- self.ttls = {}
-
- async def setex(self, key, ttl, value):
- self.values[key] = value
- self.ttls[key] = ttl
-
- async def exists(self, key):
- return int(key in self.values)
-
-
-def test_access_tokens_include_unique_jti_claims():
- first_token = JWTService.create_access_token({"sub": "user-id"})
- second_token = JWTService.create_access_token({"sub": "user-id"})
-
- first_payload = JWTService.decode_token(first_token)
- second_payload = JWTService.decode_token(second_token)
-
- assert first_payload["jti"]
- assert second_payload["jti"]
- assert first_payload["jti"] != second_payload["jti"]
-
-
-def test_token_revocation_stores_access_token_jti_until_expiry():
- async def run():
- redis = FakeRedis()
- token = JWTService.create_access_token({"sub": "user-id"})
- payload = JWTService.decode_token(token)
-
- await TokenRevocationService.revoke_access_token(token, redis)
-
- key = f"revoked_access_token:{payload['jti']}"
- assert redis.values[key] == "1"
- assert redis.ttls[key] > 0
- assert await TokenRevocationService.is_access_token_revoked(token, redis) is True
-
- asyncio.run(run())
-
-
-def test_token_revocation_ignores_already_expired_tokens():
- async def run():
- redis = FakeRedis()
- token = jwt.encode(
- {
- "sub": "user-id",
- "jti": "expired-token-id",
- "exp": datetime.now(timezone.utc) - timedelta(minutes=1),
- },
- settings.SECRET_KEY,
- algorithm=settings.ALGORITHM,
- )
-
- await TokenRevocationService.revoke_access_token(token, redis)
-
- assert redis.values == {}
-
- asyncio.run(run())
diff --git a/tests/core/test_user_seed.py b/tests/core/test_user_seed.py
deleted file mode 100644
index d5d8178..0000000
--- a/tests/core/test_user_seed.py
+++ /dev/null
@@ -1,167 +0,0 @@
-from dataclasses import dataclass
-
-import pytest
-
-from src.core.authorization.permissions import (
- ADMIN_ROLE,
- DEFAULT_USER_ROLE,
- MANAGER_ROLE,
- VIEWER_ROLE,
-)
-from src.core.security.password import PasswordSerrvice
-from src.core.seed.user import SeedUserConfig, seed_user
-from src.modules.user.domain.entities.user import User
-
-
-class FakeUserRepository:
- def __init__(self):
- self.users: dict[str, User] = {}
-
- async def get_by_email(self, email: str) -> User | None:
- return self.users.get(email)
-
- async def save(self, user: User) -> User:
- self.users[user.email] = user
- return user
-
-
-class FakeAuthorizationService:
- def __init__(self):
- self.assignments: list[tuple[str, str]] = []
-
- async def assign_role(self, subject: str, role: str) -> None:
- self.assignments.append((subject, role))
-
-
-@dataclass(frozen=True)
-class SeedSettings:
- APP_ENV: str = "production"
- SEED_ADMIN_EMAIL: str = "admin@example.com"
- SEED_ADMIN_PASSWORD: str = "admin-password"
- SEED_ADMIN_USERNAME: str = "admin"
- SEED_ADMIN_FULLNAME: str = "System Administrator"
- SEED_DEVELOPMENT_USERS_PASSWORD: str = "development-password"
-
-
-@pytest.mark.anyio
-async def test_seed_user_creates_admin_user_with_hashed_password_and_role():
- user_repository = FakeUserRepository()
- authorization_service = FakeAuthorizationService()
-
- result = await seed_user(
- user_repository=user_repository,
- authorization_service=authorization_service,
- config=SeedUserConfig.from_settings(SeedSettings()),
- )
-
- user = user_repository.users["admin@example.com"]
- assert result.users_created == 1
- assert result.roles_assigned == 1
- assert user.username == "admin"
- assert user.fullname == "System Administrator"
- assert user.password != "admin-password"
- assert PasswordSerrvice.verify("admin-password", user.password)
- assert authorization_service.assignments == [(str(user.id), ADMIN_ROLE)]
-
-
-@pytest.mark.anyio
-async def test_seed_user_is_idempotent_by_email():
- user_repository = FakeUserRepository()
- authorization_service = FakeAuthorizationService()
- config = SeedUserConfig.from_settings(SeedSettings())
-
- await seed_user(
- user_repository=user_repository,
- authorization_service=authorization_service,
- config=config,
- )
- result = await seed_user(
- user_repository=user_repository,
- authorization_service=authorization_service,
- config=config,
- )
-
- assert result.users_created == 0
- assert result.roles_assigned == 0
- assert len(user_repository.users) == 1
- assert len(authorization_service.assignments) == 1
-
-
-@pytest.mark.anyio
-async def test_seed_user_skips_when_admin_credentials_are_missing():
- user_repository = FakeUserRepository()
- authorization_service = FakeAuthorizationService()
-
- result = await seed_user(
- user_repository=user_repository,
- authorization_service=authorization_service,
- config=SeedUserConfig(
- app_env="production",
- admin_email="",
- admin_password="",
- admin_username="admin",
- admin_fullname="System Administrator",
- development_users_password="development-password",
- ),
- )
-
- assert result.users_created == 0
- assert result.roles_assigned == 0
- assert user_repository.users == {}
- assert authorization_service.assignments == []
-
-
-@pytest.mark.anyio
-async def test_seed_user_creates_development_users_with_different_roles():
- user_repository = FakeUserRepository()
- authorization_service = FakeAuthorizationService()
-
- result = await seed_user(
- user_repository=user_repository,
- authorization_service=authorization_service,
- config=SeedUserConfig(
- app_env="development",
- admin_email="",
- admin_password="",
- admin_username="admin",
- admin_fullname="System Administrator",
- development_users_password="development-password",
- ),
- )
-
- assert result.users_created == 3
- assert result.roles_assigned == 3
- assert set(user_repository.users.keys()) == {
- "user@example.com",
- "manager@example.com",
- "viewer@example.com",
- }
- assert {
- role for _, role in authorization_service.assignments
- } == {DEFAULT_USER_ROLE, MANAGER_ROLE, VIEWER_ROLE}
- for user in user_repository.users.values():
- assert PasswordSerrvice.verify("development-password", user.password)
-
-
-@pytest.mark.anyio
-async def test_seed_user_skips_development_users_outside_development():
- user_repository = FakeUserRepository()
- authorization_service = FakeAuthorizationService()
-
- result = await seed_user(
- user_repository=user_repository,
- authorization_service=authorization_service,
- config=SeedUserConfig(
- app_env="production",
- admin_email="",
- admin_password="",
- admin_username="admin",
- admin_fullname="System Administrator",
- development_users_password="development-password",
- ),
- )
-
- assert result.users_created == 0
- assert result.roles_assigned == 0
- assert user_repository.users == {}
- assert authorization_service.assignments == []
diff --git a/tests/shared/test_unit_of_work.py b/tests/shared/test_unit_of_work.py
deleted file mode 100644
index 1f4b533..0000000
--- a/tests/shared/test_unit_of_work.py
+++ /dev/null
@@ -1,84 +0,0 @@
-import asyncio
-
-from src.shared.database.unit_of_work import SQLAlchemyUnitOfWork
-
-
-class FakeSession:
- def __init__(self):
- self.committed = False
- self.rolled_back = False
-
- async def commit(self):
- self.committed = True
-
- async def rollback(self):
- self.rolled_back = True
-
-
-def test_sqlalchemy_unit_of_work_commits_session():
- async def run():
- session = FakeSession()
- unit_of_work = SQLAlchemyUnitOfWork(session)
-
- await unit_of_work.commit()
-
- assert session.committed is True
-
- asyncio.run(run())
-
-
-def test_sqlalchemy_unit_of_work_rolls_back_session():
- async def run():
- session = FakeSession()
- unit_of_work = SQLAlchemyUnitOfWork(session)
-
- await unit_of_work.rollback()
-
- assert session.rolled_back is True
-
- asyncio.run(run())
-
-
-def test_sqlalchemy_unit_of_work_rolls_back_when_scope_exits_without_commit():
- async def run():
- session = FakeSession()
- unit_of_work = SQLAlchemyUnitOfWork(session)
-
- async with unit_of_work:
- pass
-
- assert session.rolled_back is True
- assert session.committed is False
-
- asyncio.run(run())
-
-
-def test_sqlalchemy_unit_of_work_does_not_roll_back_after_commit():
- async def run():
- session = FakeSession()
- unit_of_work = SQLAlchemyUnitOfWork(session)
-
- async with unit_of_work:
- await unit_of_work.commit()
-
- assert session.committed is True
- assert session.rolled_back is False
-
- asyncio.run(run())
-
-
-def test_sqlalchemy_unit_of_work_rolls_back_and_propagates_scope_exception():
- async def run():
- session = FakeSession()
- unit_of_work = SQLAlchemyUnitOfWork(session)
-
- try:
- async with unit_of_work:
- raise RuntimeError("write failed")
- except RuntimeError as exc:
- assert str(exc) == "write failed"
-
- assert session.rolled_back is True
- assert session.committed is False
-
- asyncio.run(run())
diff --git a/tests/test_application_validation.py b/tests/test_application_validation.py
index 4c8b2aa..4f95510 100644
--- a/tests/test_application_validation.py
+++ b/tests/test_application_validation.py
@@ -12,26 +12,26 @@
from src.modules.todo.application.update_todo.validation import (
validate_update_todo_command,
)
-from src.modules.user.application.detail_user.query import DetailUserQuery
-from src.modules.user.application.detail_user.validation import (
- validate_detail_user_query,
-)
-from src.modules.user.application.login_user.command import LoginUserCommand
-from src.modules.user.application.login_user.validation import (
+from src.modules.user.application.auth.login_user.command import LoginUserCommand
+from src.modules.user.application.auth.login_user.validation import (
validate_login_user_command,
)
-from src.modules.user.application.logout_user.command import LogoutUserCommand
-from src.modules.user.application.logout_user.validation import (
+from src.modules.user.application.auth.logout_user.command import LogoutUserCommand
+from src.modules.user.application.auth.logout_user.validation import (
validate_logout_user_command,
)
-from src.modules.user.application.refresh_token.command import RefreshTokenCommand
-from src.modules.user.application.refresh_token.validation import (
+from src.modules.user.application.auth.refresh_token.command import RefreshTokenCommand
+from src.modules.user.application.auth.refresh_token.validation import (
validate_refresh_token_command,
)
-from src.modules.user.application.register_user.command import RegisterUserCommand
-from src.modules.user.application.register_user.validation import (
+from src.modules.user.application.auth.register_user.command import RegisterUserCommand
+from src.modules.user.application.auth.register_user.validation import (
validate_register_user_command,
)
+from src.modules.user.application.detail_user.query import DetailUserQuery
+from src.modules.user.application.detail_user.validation import (
+ validate_detail_user_query,
+)
def test_create_todo_validation_rejects_blank_title():
diff --git a/tests/test_database_relationships.py b/tests/test_database_relationships.py
new file mode 100644
index 0000000..1e04eae
--- /dev/null
+++ b/tests/test_database_relationships.py
@@ -0,0 +1,55 @@
+import pytest
+from sqlalchemy import Uuid
+from sqlalchemy.orm import configure_mappers
+
+import src.modules.authorization.infrastructure.models.permission_model # noqa: F401
+import src.modules.authorization.infrastructure.models.resource_model # noqa: F401
+import src.modules.authorization.infrastructure.models.role_model # noqa: F401
+import src.modules.authorization.infrastructure.models.role_permission_model # noqa: F401
+import src.modules.authorization.infrastructure.models.user_has_role_model # noqa: F401
+import src.modules.todo.infrastructure.models.todo_model # noqa: F401
+import src.modules.user.infrastructure.models # noqa: F401
+from src.shared.database.model import Base
+
+
+FOREIGN_KEYS = (
+ ("permissions", "resource_id", "authorization_resources.id"),
+ ("role_permissions", "role_id", "roles.id"),
+ ("role_permissions", "permission_id", "permissions.id"),
+ ("todos", "user_id", "users.id"),
+ ("user_has_roles", "user_id", "users.id"),
+ ("user_has_roles", "role_id", "roles.id"),
+ ("user_profiles", "user_id", "users.id"),
+ ("user_security", "user_id", "users.id"),
+ ("user_settings", "user_id", "users.id"),
+ ("user_contacts", "user_id", "users.id"),
+ ("user_addresses", "user_id", "users.id"),
+ ("user_verifications", "user_id", "users.id"),
+ ("user_sessions", "user_id", "users.id"),
+)
+
+NORMALIZED_USER_TABLES = (
+ "user_profiles",
+ "user_security",
+ "user_settings",
+ "user_contacts",
+ "user_addresses",
+ "user_verifications",
+ "user_sessions",
+)
+
+
+def test_all_mappers_configure_with_declared_relationship_joins():
+ configure_mappers()
+
+
+@pytest.mark.parametrize(("table", "column", "target"), FOREIGN_KEYS)
+def test_relationship_column_declares_expected_foreign_key(table, column, target):
+ foreign_keys = Base.metadata.tables[table].c[column].foreign_keys
+
+ assert {foreign_key.target_fullname for foreign_key in foreign_keys} == {target}
+
+
+@pytest.mark.parametrize("table", NORMALIZED_USER_TABLES)
+def test_normalized_user_identifier_uses_uuid(table):
+ assert isinstance(Base.metadata.tables[table].c.user_id.type, Uuid)
diff --git a/tests/test_user_seed.py b/tests/test_user_seed.py
new file mode 100644
index 0000000..740b6bf
--- /dev/null
+++ b/tests/test_user_seed.py
@@ -0,0 +1,172 @@
+import asyncio
+from uuid import uuid4
+
+from src.core.seed.user import SeedUserConfig, seed_user
+from src.modules.authorization.domain.permissions import (
+ ADMIN_ROLE,
+ DEFAULT_USER_ROLE,
+ MANAGER_ROLE,
+ VIEWER_ROLE,
+)
+from src.modules.user.domain.entities.user import User, UserProfile
+
+
+class FakeUserRepository:
+ def __init__(self, existing_users: tuple[User, ...] = ()) -> None:
+ self.users = {user.email: user for user in existing_users}
+ self.saved_users: list[User] = []
+ self.saved_profiles: list[UserProfile] = []
+
+ async def get_by_email(self, email: str) -> User | None:
+ return self.users.get(email)
+
+ async def save(self, user: User) -> User:
+ self.users[user.email] = user
+ self.saved_users.append(user)
+ return user
+
+ async def save_profile(self, profile: UserProfile) -> UserProfile:
+ self.saved_profiles.append(profile)
+ return profile
+
+
+class FakeAuthorizationService:
+ def __init__(self) -> None:
+ self.assignments: list[tuple[str, str]] = []
+
+ async def assign_role(self, subject: str, role: str) -> None:
+ self.assignments.append((subject, role))
+
+
+def test_seed_user_creates_admin_with_normalized_profile(monkeypatch):
+ monkeypatch.setattr(
+ "src.core.seed.user.PasswordSerrvice.hash",
+ lambda password: f"hashed:{password}",
+ )
+ repository = FakeUserRepository()
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="production",
+ admin_email="admin@example.com",
+ admin_password="secret-password",
+ admin_username="admin",
+ admin_fullname="System Administrator",
+ ),
+ )
+ )
+
+ assert result.users_created == 1
+ assert result.roles_assigned == 1
+ assert len(repository.saved_users) == 1
+ saved_user = repository.saved_users[0]
+ assert saved_user.email == "admin@example.com"
+ assert saved_user.username == "admin"
+ assert saved_user.password_hash == "hashed:secret-password"
+ assert repository.saved_profiles == [
+ UserProfile(user_id=saved_user.id, display_name="System Administrator")
+ ]
+ assert authorization.assignments == [(str(saved_user.id), ADMIN_ROLE)]
+
+
+def test_seed_user_creates_development_users_and_profiles(monkeypatch):
+ monkeypatch.setattr(
+ "src.core.seed.user.PasswordSerrvice.hash",
+ lambda password: f"hashed:{password}",
+ )
+ repository = FakeUserRepository()
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="development",
+ admin_email="",
+ admin_password="",
+ development_users_password="demo-password",
+ ),
+ )
+ )
+
+ assert result.users_created == 3
+ assert result.roles_assigned == 3
+ assert [user.email for user in repository.saved_users] == [
+ "user@example.com",
+ "manager@example.com",
+ "viewer@example.com",
+ ]
+ assert [profile.display_name for profile in repository.saved_profiles] == [
+ "Default User",
+ "Todo Manager",
+ "Todo Viewer",
+ ]
+ assert [role for _, role in authorization.assignments] == [
+ DEFAULT_USER_ROLE,
+ MANAGER_ROLE,
+ VIEWER_ROLE,
+ ]
+
+
+def test_seed_user_does_not_modify_an_existing_user(monkeypatch):
+ monkeypatch.setattr(
+ "src.core.seed.user.PasswordSerrvice.hash",
+ lambda password: f"hashed:{password}",
+ )
+ existing_user = User(
+ id=uuid4(),
+ email="admin@example.com",
+ password_hash="existing-hash",
+ username="existing-admin",
+ )
+ repository = FakeUserRepository((existing_user,))
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="production",
+ admin_email="admin@example.com",
+ admin_password="new-password",
+ admin_username="admin",
+ admin_fullname="System Administrator",
+ ),
+ )
+ )
+
+ assert result.users_created == 0
+ assert result.roles_assigned == 0
+ assert repository.saved_users == []
+ assert repository.saved_profiles == []
+ assert authorization.assignments == []
+ assert repository.users[existing_user.email] == existing_user
+
+
+def test_seed_user_skips_users_without_credentials():
+ repository = FakeUserRepository()
+ authorization = FakeAuthorizationService()
+
+ result = asyncio.run(
+ seed_user(
+ user_repository=repository,
+ authorization_service=authorization,
+ config=SeedUserConfig(
+ app_env="production",
+ admin_email="",
+ admin_password="",
+ ),
+ )
+ )
+
+ assert result.users_created == 0
+ assert result.roles_assigned == 0
+ assert repository.saved_users == []
+ assert repository.saved_profiles == []
+ assert authorization.assignments == []
diff --git a/tests/todo/test_todo_flow.py b/tests/todo/test_todo_flow.py
deleted file mode 100644
index 3626d3c..0000000
--- a/tests/todo/test_todo_flow.py
+++ /dev/null
@@ -1,106 +0,0 @@
-import asyncio
-import inspect
-from uuid import uuid4
-
-import pytest
-from fastapi import HTTPException
-
-from src.modules.todo.application.delete_todo.handler import DeleteTodoHandler
-from src.modules.todo.domain.entities.todo import Todo
-from src.modules.todo.infrastructure.repositories.todo_repository import (
- SQLAlchemyTodoRepository,
-)
-from src.modules.todo.presentation.routers.todo_router import delete_todo
-
-
-class FakeTodoRepository:
- def __init__(self, todo=None):
- self.todo = todo
- self.deleted_id = None
-
- async def get_by_id(self, todo_id):
- if self.todo and self.todo.id == todo_id:
- return self.todo
- return None
-
- async def delete(self, todo_id):
- self.deleted_id = todo_id
-
-
-class FakeUnitOfWork:
- def __init__(self):
- self.committed = False
- self.rolled_back = False
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, traceback):
- if exc_type is not None or not self.committed:
- await self.rollback()
- return False
-
- async def commit(self):
- self.committed = True
-
- async def rollback(self):
- self.rolled_back = True
-
-
-def test_delete_todo_checks_ownership_before_delete():
- async def run():
- owner_id = uuid4()
- todo = Todo.create(title="Task", user_id=owner_id)
- repo = FakeTodoRepository(todo)
- unit_of_work = FakeUnitOfWork()
-
- await delete_todo(
- todo_id=todo.id,
- current_user={"id": owner_id},
- handler=DeleteTodoHandler(repo, unit_of_work),
- )
-
- assert repo.deleted_id == todo.id
- assert unit_of_work.committed is True
- assert unit_of_work.rolled_back is False
-
- asyncio.run(run())
-
-
-def test_delete_todo_rejects_missing_todo():
- async def run():
- repo = FakeTodoRepository()
-
- with pytest.raises(HTTPException) as exc_info:
- await delete_todo(
- todo_id=uuid4(),
- current_user={"id": uuid4()},
- handler=DeleteTodoHandler(repo, FakeUnitOfWork()),
- )
-
- assert exc_info.value.status_code == 404
-
- asyncio.run(run())
-
-
-def test_delete_todo_rejects_wrong_owner():
- async def run():
- todo = Todo.create(title="Task", user_id=uuid4())
- repo = FakeTodoRepository(todo)
-
- with pytest.raises(HTTPException) as exc_info:
- await delete_todo(
- todo_id=todo.id,
- current_user={"id": uuid4()},
- handler=DeleteTodoHandler(repo, FakeUnitOfWork()),
- )
-
- assert exc_info.value.status_code == 403
-
- asyncio.run(run())
-
-
-def test_todo_repository_save_uses_merge_for_upsert():
- source = inspect.getsource(SQLAlchemyTodoRepository.save)
-
- assert ".merge(" in source
diff --git a/tests/user/test_login_flow.py b/tests/user/test_login_flow.py
deleted file mode 100644
index 7e4c78a..0000000
--- a/tests/user/test_login_flow.py
+++ /dev/null
@@ -1,121 +0,0 @@
-import asyncio
-from datetime import datetime, timezone
-
-import pytest
-
-from src.core.config.setting import get_settings
-from src.core.security.password import PasswordSerrvice
-from src.modules.user.application.login_user.command import LoginUserCommand
-from src.modules.user.application.login_user.handler import LoginUserCommandHandler
-from src.modules.user.domain.entities.user import User
-from src.shared.exceptions.credential_exception import InvalidCredentialsError
-
-settings = get_settings()
-
-
-class FakeUserRepository:
- def __init__(self, user):
- self.user = user
-
- async def get_by_email(self, email: str):
- if self.user.email == email:
- return self.user
- return None
-
- async def get_by_id(self, user_id):
- return self.user if self.user.id == user_id else None
-
- async def save(self, user):
- self.user = user
- return user
-
-
-class FakeRefreshTokenRepository:
- def __init__(self):
- self.saved_token = None
-
- async def get_by_token_hash(self, token_hash: str):
- return None
-
- async def save(self, refresh_token):
- self.saved_token = refresh_token
- return refresh_token
-
- async def revoke_by_user_id(self, user_id):
- return None
-
-
-class FakeUnitOfWork:
- def __init__(self):
- self.committed = False
- self.rolled_back = False
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, traceback):
- if exc_type is not None or not self.committed:
- await self.rollback()
- return False
-
- async def commit(self):
- self.committed = True
-
- async def rollback(self):
- self.rolled_back = True
-
-
-def test_login_persists_refresh_token_expiry_in_minutes(monkeypatch):
- async def run():
- monkeypatch.setattr(settings, "REFRESH_TOKEN_EXPIRE_MINUTES", 15)
- user = User.create(
- email="person@example.com",
- password=PasswordSerrvice.hash("plain-secret"),
- )
- refresh_token_repo = FakeRefreshTokenRepository()
- unit_of_work = FakeUnitOfWork()
-
- before = datetime.now(timezone.utc)
- result = await LoginUserCommandHandler(
- FakeUserRepository(user),
- refresh_token_repo,
- unit_of_work,
- ).execute(
- LoginUserCommand(
- username="person@example.com",
- password="plain-secret",
- )
- )
- after = datetime.now(timezone.utc)
-
- assert result["access_token"]
- assert result["refresh_token"]
- expires_at = refresh_token_repo.saved_token.expires_at.timestamp()
- assert before.timestamp() + (15 * 60) <= expires_at
- assert expires_at <= after.timestamp() + (15 * 60)
- assert unit_of_work.committed is True
- assert unit_of_work.rolled_back is False
-
- asyncio.run(run())
-
-
-def test_login_uses_generic_error_for_missing_user():
- async def run():
- user = User.create(
- email="person@example.com",
- password=PasswordSerrvice.hash("plain-secret"),
- )
-
- with pytest.raises(InvalidCredentialsError, match="Incorrect email or password"):
- await LoginUserCommandHandler(
- FakeUserRepository(user),
- FakeRefreshTokenRepository(),
- FakeUnitOfWork(),
- ).execute(
- LoginUserCommand(
- username="missing@example.com",
- password="plain-secret",
- )
- )
-
- asyncio.run(run())
diff --git a/tests/user/test_logout_flow.py b/tests/user/test_logout_flow.py
deleted file mode 100644
index 81e5709..0000000
--- a/tests/user/test_logout_flow.py
+++ /dev/null
@@ -1,67 +0,0 @@
-import asyncio
-from uuid import uuid4
-
-from src.modules.user.application.logout_user.command import LogoutUserCommand
-from src.modules.user.application.logout_user.handler import LogoutUserCommandHandler
-
-
-class FakeRefreshTokenRepository:
- def __init__(self):
- self.revoked_user_ids = []
-
- async def revoke_by_user_id(self, user_id):
- self.revoked_user_ids.append(user_id)
-
-
-class FakeTokenRevocationService:
- def __init__(self):
- self.revoked_access_tokens = []
-
- async def revoke_access_token(self, token):
- self.revoked_access_tokens.append(token)
-
-
-class FakeUnitOfWork:
- def __init__(self):
- self.committed = False
- self.rolled_back = False
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, traceback):
- if exc_type is not None or not self.committed:
- await self.rollback()
- return False
-
- async def commit(self):
- self.committed = True
-
- async def rollback(self):
- self.rolled_back = True
-
-
-def test_logout_revokes_refresh_tokens_and_current_access_token():
- async def run():
- user_id = str(uuid4())
- refresh_token_repo = FakeRefreshTokenRepository()
- token_revocation_service = FakeTokenRevocationService()
- unit_of_work = FakeUnitOfWork()
-
- await LogoutUserCommandHandler(
- refresh_token_repo,
- unit_of_work,
- token_revocation_service,
- ).execute(
- LogoutUserCommand(
- user_id=user_id,
- access_token="current-access-token",
- )
- )
-
- assert refresh_token_repo.revoked_user_ids == [user_id]
- assert token_revocation_service.revoked_access_tokens == ["current-access-token"]
- assert unit_of_work.committed is True
- assert unit_of_work.rolled_back is False
-
- asyncio.run(run())
diff --git a/tests/user/test_refresh_token_flow.py b/tests/user/test_refresh_token_flow.py
deleted file mode 100644
index 22298fd..0000000
--- a/tests/user/test_refresh_token_flow.py
+++ /dev/null
@@ -1,196 +0,0 @@
-import asyncio
-import hashlib
-import inspect
-from datetime import datetime, timedelta, timezone
-from uuid import uuid4
-
-import pytest
-
-from src.core.security.jwt import JWTService
-from src.modules.user.application.refresh_token.command import RefreshTokenCommand
-from src.modules.user.application.refresh_token import handler as refresh_handler_module
-from src.modules.user.application.refresh_token.handler import RefreshTokenCommandHandler
-from src.modules.user.domain.entities.refresh_token import RefreshToken
-from src.modules.user.domain.repositories.refresh_token_repository import (
- RefreshTokenRepository,
-)
-from src.modules.user.presentation.dependency import get_refresh_token_handler
-from src.shared.exceptions.credential_exception import InvalidRefreshTokenError
-
-
-class FakeRefreshTokenRepository:
- def __init__(self, stored_token=None):
- self.stored_token = stored_token
- self.saved_tokens = []
-
- async def get_by_token_hash(self, token_hash: str):
- if self.stored_token and self.stored_token.token_hash == token_hash:
- return self.stored_token
- return None
-
- async def save(self, refresh_token: RefreshToken):
- self.saved_tokens.append(refresh_token)
- return refresh_token
-
- async def revoke_by_user_id(self, user_id):
- return None
-
-
-class FakeUnitOfWork:
- def __init__(self):
- self.committed = False
- self.rolled_back = False
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, traceback):
- if exc_type is not None or not self.committed:
- await self.rollback()
- return False
-
- async def commit(self):
- self.committed = True
-
- async def rollback(self):
- self.rolled_back = True
-
-
-class FailingSecondSaveRefreshTokenRepository(FakeRefreshTokenRepository):
- async def save(self, refresh_token: RefreshToken):
- self.saved_tokens.append(refresh_token)
- if len(self.saved_tokens) == 2:
- raise RuntimeError("second save failed")
- return refresh_token
-
-
-def test_refresh_token_command_uses_refresh_token_only():
- command = RefreshTokenCommand(token="raw-refresh-token")
-
- assert command.token == "raw-refresh-token"
-
-
-def test_refresh_token_handler_dependency_uses_refresh_token_repository():
- dependency = inspect.signature(get_refresh_token_handler).parameters[
- "refresh_token_repo"
- ]
-
- assert dependency.annotation is RefreshTokenRepository
-
-
-def test_refresh_token_rejects_unknown_token():
- async def run():
- handler = RefreshTokenCommandHandler(
- FakeRefreshTokenRepository(),
- FakeUnitOfWork(),
- )
-
- with pytest.raises(InvalidRefreshTokenError, match="Invalid refresh token"):
- await handler.execute(RefreshTokenCommand(token="unknown-token"))
-
- asyncio.run(run())
-
-
-def test_refresh_token_rejects_access_token_even_when_hash_exists():
- async def run():
- user_id = uuid4()
- access_token = JWTService.create_access_token({"sub": str(user_id)})
- token_hash = hashlib.sha256(access_token.encode()).hexdigest()
- stored_token = RefreshToken.create(
- user_id=user_id,
- token_hash=token_hash,
- expires_at=datetime.now(timezone.utc) + timedelta(days=1),
- )
- handler = RefreshTokenCommandHandler(
- FakeRefreshTokenRepository(stored_token),
- FakeUnitOfWork(),
- )
-
- with pytest.raises(InvalidRefreshTokenError, match="Invalid refresh token"):
- await handler.execute(RefreshTokenCommand(token=access_token))
-
- asyncio.run(run())
-
-
-def test_refresh_token_rotates_token_and_revokes_existing_token():
- async def run():
- user_id = uuid4()
- raw_token = JWTService.create_refresh_token({"sub": str(user_id)})
- token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
- stored_token = RefreshToken.create(
- user_id=user_id,
- token_hash=token_hash,
- expires_at=datetime.now(timezone.utc) + timedelta(days=1),
- )
- repo = FakeRefreshTokenRepository(stored_token)
- unit_of_work = FakeUnitOfWork()
-
- result = await RefreshTokenCommandHandler(repo, unit_of_work).execute(
- RefreshTokenCommand(token=raw_token)
- )
-
- assert result["access_token"]
- assert result["refresh_token"]
- assert stored_token.is_revoked is True
- assert repo.saved_tokens[0] is stored_token
- assert repo.saved_tokens[1].user_id == user_id
- assert repo.saved_tokens[1].is_revoked is False
- assert unit_of_work.committed is True
- assert unit_of_work.rolled_back is False
-
- asyncio.run(run())
-
-
-def test_refresh_token_rotation_persists_new_expiry_in_minutes(monkeypatch):
- async def run():
- monkeypatch.setattr(
- refresh_handler_module.settings,
- "REFRESH_TOKEN_EXPIRE_MINUTES",
- 15,
- )
- user_id = uuid4()
- raw_token = JWTService.create_refresh_token({"sub": str(user_id)})
- token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
- stored_token = RefreshToken.create(
- user_id=user_id,
- token_hash=token_hash,
- expires_at=datetime.now(timezone.utc) + timedelta(days=1),
- )
- repo = FakeRefreshTokenRepository(stored_token)
- unit_of_work = FakeUnitOfWork()
-
- before = datetime.now(timezone.utc)
- await RefreshTokenCommandHandler(repo, unit_of_work).execute(
- RefreshTokenCommand(token=raw_token)
- )
- after = datetime.now(timezone.utc)
-
- new_refresh_token = repo.saved_tokens[1]
- assert before.timestamp() + (15 * 60) <= new_refresh_token.expires_at.timestamp()
- assert new_refresh_token.expires_at.timestamp() <= after.timestamp() + (15 * 60)
-
- asyncio.run(run())
-
-
-def test_refresh_token_rotation_rolls_back_when_new_token_save_fails():
- async def run():
- user_id = uuid4()
- raw_token = JWTService.create_refresh_token({"sub": str(user_id)})
- token_hash = hashlib.sha256(raw_token.encode()).hexdigest()
- stored_token = RefreshToken.create(
- user_id=user_id,
- token_hash=token_hash,
- expires_at=datetime.now(timezone.utc) + timedelta(days=1),
- )
- repo = FailingSecondSaveRefreshTokenRepository(stored_token)
- unit_of_work = FakeUnitOfWork()
-
- with pytest.raises(RuntimeError, match="second save failed"):
- await RefreshTokenCommandHandler(repo, unit_of_work).execute(
- RefreshTokenCommand(token=raw_token)
- )
-
- assert unit_of_work.committed is False
- assert unit_of_work.rolled_back is True
-
- asyncio.run(run())
diff --git a/tests/user/test_refresh_token_repository.py b/tests/user/test_refresh_token_repository.py
deleted file mode 100644
index e67662d..0000000
--- a/tests/user/test_refresh_token_repository.py
+++ /dev/null
@@ -1,55 +0,0 @@
-import asyncio
-from datetime import datetime, timedelta, timezone
-from uuid import uuid4
-
-from src.modules.user.domain.entities.refresh_token import RefreshToken
-from src.modules.user.infrastructure.repositories.refresh_token_repository import (
- SQLAlchemyRefreshTokenRepository,
-)
-
-
-class FakeSession:
- def __init__(self):
- self.added_model = None
- self.merged_model = None
- self.committed = False
- self.flushed = False
- self.refreshed_model = None
-
- def add(self, model):
- self.added_model = model
-
- async def merge(self, model):
- self.merged_model = model
- return model
-
- async def commit(self):
- self.committed = True
-
- async def flush(self):
- self.flushed = True
-
- async def refresh(self, model):
- self.refreshed_model = model
-
-
-def test_refresh_token_save_merges_existing_identity():
- async def run():
- session = FakeSession()
- repository = SQLAlchemyRefreshTokenRepository(session)
- refresh_token = RefreshToken.create(
- user_id=uuid4(),
- token_hash="token-hash",
- expires_at=datetime.now(timezone.utc) + timedelta(days=1),
- )
-
- saved_token = await repository.save(refresh_token)
-
- assert session.added_model is None
- assert session.merged_model is not None
- assert session.committed is False
- assert session.flushed is True
- assert session.refreshed_model is session.merged_model
- assert saved_token.id == refresh_token.id
-
- asyncio.run(run())
diff --git a/tests/user/test_user_flow.py b/tests/user/test_user_flow.py
deleted file mode 100644
index ab89d62..0000000
--- a/tests/user/test_user_flow.py
+++ /dev/null
@@ -1,106 +0,0 @@
-import asyncio
-from uuid import uuid4
-
-from src.core.dependency import auth
-from src.core.security.password import PasswordSerrvice
-from src.modules.user.application.register_user.command import RegisterUserCommand
-from src.modules.user.application.register_user.handler import (
- RegisterUserCommandHandler,
-)
-from src.modules.user.domain.entities.user import User
-
-
-class FakeUserRepository:
- def __init__(self):
- self.saved_user = None
- self.user = None
-
- async def get_by_email(self, email: str):
- return None
-
- async def get_by_id(self, user_id):
- return self.user if str(self.user.id) == str(user_id) else None
-
- async def save(self, user: User):
- self.saved_user = user
- return user
-
-
-class FakeRequest:
- def __init__(self, user_id):
- self.state = type(
- "State",
- (),
- {"user_id": str(user_id), "token_payload": {"sub": str(user_id)}},
- )()
-
-
-class FakeUnitOfWork:
- def __init__(self):
- self.committed = False
- self.rolled_back = False
-
- async def __aenter__(self):
- return self
-
- async def __aexit__(self, exc_type, exc, traceback):
- if exc_type is not None or not self.committed:
- await self.rollback()
- return False
-
- async def commit(self):
- self.committed = True
-
- async def rollback(self):
- self.rolled_back = True
-
-
-class FakeAuthorizationService:
- def __init__(self):
- self.assigned_roles = []
-
- async def can(self, subject: str, resource: str, action: str) -> bool:
- return True
-
- async def assign_role(self, subject: str, role: str) -> None:
- self.assigned_roles.append((subject, role))
-
-
-def test_create_user_hashes_password_and_awaits_save():
- async def run():
- repo = FakeUserRepository()
- unit_of_work = FakeUnitOfWork()
- authorization_service = FakeAuthorizationService()
- handler = RegisterUserCommandHandler(repo, unit_of_work, authorization_service)
-
- user = await handler.execute(
- RegisterUserCommand(email="person@example.com", password="plain-secret")
- )
-
- assert isinstance(user, User)
- assert user.email == "person@example.com"
- assert user.password != "plain-secret"
- assert PasswordSerrvice.verify_password("plain-secret", user.password)
- assert repo.saved_user is user
- assert unit_of_work.committed is True
- assert unit_of_work.rolled_back is False
- assert authorization_service.assigned_roles == [(str(user.id), "user")]
-
- asyncio.run(run())
-
-
-def test_get_current_user_reads_user_id_from_request_state(monkeypatch):
- async def run():
- repo = FakeUserRepository()
- repo.user = User(id=uuid4(), email="person@example.com", password="hashed")
- monkeypatch.setattr(auth, "SQLAlchemyUserRepository", lambda db: repo)
-
- current_user = await auth.get_current_user(
- request=FakeRequest(repo.user.id), db=None
- )
-
- assert current_user["id"] == repo.user.id
- assert current_user["email"] == repo.user.email
- assert current_user["raw_payload"] == {"sub": str(repo.user.id)}
-
- asyncio.run(run())