fastapi-forge-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (181) hide show
  1. fastapi_forge/__init__.py +7 -0
  2. fastapi_forge/__main__.py +6 -0
  3. fastapi_forge/cli.py +211 -0
  4. fastapi_forge/templates/with_rbac/Dockerfile +31 -0
  5. fastapi_forge/templates/with_rbac/README.md +121 -0
  6. fastapi_forge/templates/with_rbac/_dockerignore +16 -0
  7. fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
  8. fastapi_forge/templates/with_rbac/_gitignore +19 -0
  9. fastapi_forge/templates/with_rbac/alembic/README +1 -0
  10. fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
  11. fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
  12. fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
  13. fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
  14. fastapi_forge/templates/with_rbac/alembic.ini +35 -0
  15. fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
  16. fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
  17. fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
  18. fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
  19. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  20. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
  21. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
  22. fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
  23. fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
  24. fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
  25. fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
  26. fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
  27. fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
  28. fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
  29. fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
  30. fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
  31. fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
  32. fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
  33. fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
  34. fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
  35. fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
  36. fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
  37. fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
  38. fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
  39. fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
  40. fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
  41. fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
  42. fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
  43. fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
  44. fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
  45. fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
  46. fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
  47. fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
  48. fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
  49. fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
  50. fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
  51. fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
  52. fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
  53. fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
  54. fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
  55. fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
  56. fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
  57. fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
  58. fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
  59. fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
  60. fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
  61. fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
  62. fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
  63. fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
  64. fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
  65. fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
  66. fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
  67. fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
  68. fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
  69. fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
  70. fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
  71. fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
  72. fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
  73. fastapi_forge/templates/with_rbac/app/main.py +66 -0
  74. fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
  75. fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
  76. fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
  77. fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
  78. fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
  79. fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
  80. fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
  81. fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
  82. fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
  83. fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
  84. fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
  85. fastapi_forge/templates/with_rbac/compose.yaml +33 -0
  86. fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
  87. fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
  88. fastapi_forge/templates/with_rbac/requirements.txt +16 -0
  89. fastapi_forge/templates/with_rbac/sample.env +42 -0
  90. fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
  91. fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
  92. fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
  93. fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
  94. fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
  95. fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
  96. fastapi_forge/templates/without_rbac/Dockerfile +31 -0
  97. fastapi_forge/templates/without_rbac/README.md +106 -0
  98. fastapi_forge/templates/without_rbac/_dockerignore +16 -0
  99. fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
  100. fastapi_forge/templates/without_rbac/_gitignore +19 -0
  101. fastapi_forge/templates/without_rbac/alembic/README +1 -0
  102. fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
  103. fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
  104. fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
  105. fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
  106. fastapi_forge/templates/without_rbac/alembic.ini +35 -0
  107. fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
  108. fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
  109. fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
  110. fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
  111. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
  112. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
  113. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
  114. fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
  115. fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
  116. fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
  117. fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
  118. fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
  119. fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
  120. fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
  121. fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
  122. fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
  123. fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
  124. fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
  125. fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
  126. fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
  127. fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
  128. fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
  129. fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
  130. fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
  131. fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
  132. fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
  133. fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
  134. fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
  135. fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
  136. fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
  137. fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
  138. fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
  139. fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
  140. fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
  141. fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
  142. fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
  143. fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
  144. fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
  145. fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
  146. fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
  147. fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
  148. fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
  149. fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
  150. fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
  151. fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
  152. fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
  153. fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
  154. fastapi_forge/templates/without_rbac/app/main.py +66 -0
  155. fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
  156. fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
  157. fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
  158. fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
  159. fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
  160. fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
  161. fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
  162. fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
  163. fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
  164. fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
  165. fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
  166. fastapi_forge/templates/without_rbac/compose.yaml +33 -0
  167. fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
  168. fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
  169. fastapi_forge/templates/without_rbac/requirements.txt +16 -0
  170. fastapi_forge/templates/without_rbac/sample.env +42 -0
  171. fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
  172. fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
  173. fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
  174. fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
  175. fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
  176. fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
  177. fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
  178. fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
  179. fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
  180. fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
  181. fastapi_forge_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,204 @@
1
+ """Initial authentication, RBAC, and audit schema.
2
+
3
+ Revision ID: 2255ba4f9604
4
+ Revises:
5
+ Create Date: 2026-08-13 15:57:46.646562
6
+ """
7
+
8
+ from typing import Sequence
9
+
10
+ import sqlalchemy as sa
11
+ from alembic import op
12
+ from sqlalchemy.dialects import postgresql
13
+
14
+ revision: str = "2255ba4f9604"
15
+ down_revision: str | Sequence[str] | None = None
16
+ branch_labels: str | Sequence[str] | None = None
17
+ depends_on: str | Sequence[str] | None = None
18
+
19
+
20
+ def _base_columns() -> list[sa.Column]:
21
+ return [
22
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
23
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
24
+ sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
25
+ sa.Column("is_deleted", sa.Boolean(), server_default=sa.false(), nullable=False),
26
+ sa.Column("deleted_at", sa.DateTime(timezone=True)),
27
+ sa.Column("deletion_note", sa.Text()),
28
+ ]
29
+
30
+
31
+ def upgrade() -> None:
32
+ for schema in ("auth", "rbac", "app"):
33
+ op.execute(f"CREATE SCHEMA IF NOT EXISTS {schema}")
34
+
35
+ op.create_table(
36
+ "users",
37
+ *_base_columns(),
38
+ sa.Column("email", sa.String(255), nullable=False),
39
+ sa.Column("username", sa.String(100), nullable=False),
40
+ sa.Column("full_name", sa.String(255), nullable=False),
41
+ sa.Column("hashed_password", sa.String(255), nullable=False),
42
+ sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False),
43
+ sa.Column("is_superuser", sa.Boolean(), server_default=sa.false(), nullable=False),
44
+ sa.Column("is_verified", sa.Boolean(), server_default=sa.false(), nullable=False),
45
+ sa.Column("last_login_at", sa.DateTime(timezone=True)),
46
+ sa.UniqueConstraint("email"),
47
+ sa.UniqueConstraint("username"),
48
+ schema="auth",
49
+ )
50
+ op.create_index("ix_users_email", "users", ["email"], schema="auth")
51
+ op.create_index("ix_users_username", "users", ["username"], schema="auth")
52
+
53
+ op.create_table(
54
+ "permissions",
55
+ *_base_columns(),
56
+ sa.Column("code", sa.String(100), nullable=False),
57
+ sa.Column("name", sa.String(255), nullable=False),
58
+ sa.Column("description", sa.Text()),
59
+ sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False),
60
+ sa.UniqueConstraint("code"),
61
+ schema="rbac",
62
+ )
63
+ op.create_index("ix_permissions_code", "permissions", ["code"], schema="rbac")
64
+
65
+ op.create_table(
66
+ "roles",
67
+ *_base_columns(),
68
+ sa.Column("name", sa.String(100), nullable=False),
69
+ sa.Column("description", sa.Text()),
70
+ sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False),
71
+ sa.Column(
72
+ "parent_role_id",
73
+ postgresql.UUID(as_uuid=True),
74
+ sa.ForeignKey("rbac.roles.id", ondelete="SET NULL"),
75
+ ),
76
+ sa.UniqueConstraint("name"),
77
+ schema="rbac",
78
+ )
79
+ op.create_index("ix_roles_name", "roles", ["name"], schema="rbac")
80
+ op.create_index("ix_roles_parent_role_id", "roles", ["parent_role_id"], schema="rbac")
81
+
82
+ op.create_table(
83
+ "user_roles",
84
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
85
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
86
+ sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("rbac.roles.id", ondelete="CASCADE"), nullable=False),
87
+ sa.UniqueConstraint("user_id", "role_id", name="uq_user_roles_user_id_role_id"),
88
+ schema="rbac",
89
+ )
90
+ op.create_table(
91
+ "role_permissions",
92
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
93
+ sa.Column("role_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("rbac.roles.id", ondelete="CASCADE"), nullable=False),
94
+ sa.Column("permission_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("rbac.permissions.id", ondelete="CASCADE"), nullable=False),
95
+ sa.UniqueConstraint("role_id", "permission_id", name="uq_role_permissions_role_id_permission_id"),
96
+ schema="rbac",
97
+ )
98
+ op.create_table(
99
+ "user_permissions",
100
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
101
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
102
+ sa.Column("permission_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("rbac.permissions.id", ondelete="CASCADE"), nullable=False),
103
+ sa.UniqueConstraint("user_id", "permission_id", name="uq_user_permissions_user_id_permission_id"),
104
+ schema="rbac",
105
+ )
106
+
107
+ op.create_table(
108
+ "email_verification_tokens",
109
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
110
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
111
+ sa.Column("token", sa.String(255), unique=True, nullable=False),
112
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
113
+ sa.Column("used_at", sa.DateTime(timezone=True)),
114
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
115
+ schema="auth",
116
+ )
117
+ op.create_table(
118
+ "password_reset_tokens",
119
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
120
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
121
+ sa.Column("token", sa.String(255), unique=True, nullable=False),
122
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
123
+ sa.Column("used_at", sa.DateTime(timezone=True)),
124
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
125
+ schema="auth",
126
+ )
127
+ op.create_table(
128
+ "user_sessions",
129
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
130
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
131
+ sa.Column("refresh_jti", sa.String(64), unique=True, nullable=False),
132
+ sa.Column("previous_refresh_jti", sa.String(64)),
133
+ sa.Column("user_agent", sa.Text()),
134
+ sa.Column("ip_address", sa.String(45)),
135
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
136
+ sa.Column("revoked_at", sa.DateTime(timezone=True)),
137
+ sa.Column("last_used_at", sa.DateTime(timezone=True)),
138
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
139
+ schema="auth",
140
+ )
141
+ op.create_index("ix_user_sessions_user_active", "user_sessions", ["user_id", "revoked_at"], schema="auth")
142
+
143
+ op.create_table(
144
+ "revoked_tokens",
145
+ *_base_columns(),
146
+ sa.Column("jti", sa.String(64), unique=True, nullable=False),
147
+ sa.Column("token_type", sa.String(20), nullable=False),
148
+ sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
149
+ schema="auth",
150
+ )
151
+
152
+ op.create_table(
153
+ "notifications",
154
+ *_base_columns(),
155
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="CASCADE"), nullable=False),
156
+ sa.Column("title", sa.String(255), nullable=False),
157
+ sa.Column("body", sa.Text(), nullable=False),
158
+ sa.Column("notification_type", sa.String(100), nullable=False),
159
+ sa.Column("channel", sa.String(50), server_default="in_app", nullable=False),
160
+ sa.Column("is_read", sa.Boolean(), server_default=sa.false(), nullable=False),
161
+ sa.Column("read_at", sa.DateTime(timezone=True)),
162
+ sa.Column("payload", postgresql.JSONB()),
163
+ schema="app",
164
+ )
165
+
166
+ op.create_table(
167
+ "audit_logs",
168
+ sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
169
+ sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
170
+ sa.Column("user_id", postgresql.UUID(as_uuid=True), sa.ForeignKey("auth.users.id", ondelete="SET NULL")),
171
+ sa.Column("user_email", sa.String(255)),
172
+ sa.Column("action", sa.String(100), nullable=False),
173
+ sa.Column("resource", sa.String(100), nullable=False),
174
+ sa.Column("resource_id", sa.String(255)),
175
+ sa.Column("old_values", postgresql.JSONB()),
176
+ sa.Column("new_values", postgresql.JSONB()),
177
+ sa.Column("log_metadata", postgresql.JSONB()),
178
+ sa.Column("ip_address", sa.String(45)),
179
+ sa.Column("user_agent", sa.Text()),
180
+ sa.Column("request_id", sa.String(100)),
181
+ schema="app",
182
+ )
183
+ op.create_index("ix_audit_resource_action", "audit_logs", ["resource", "action"], schema="app")
184
+ op.create_index("ix_audit_user_resource", "audit_logs", ["user_id", "resource"], schema="app")
185
+
186
+
187
+ def downgrade() -> None:
188
+ for table, schema in (
189
+ ("audit_logs", "app"),
190
+ ("notifications", "app"),
191
+ ("revoked_tokens", "auth"),
192
+ ("user_sessions", "auth"),
193
+ ("password_reset_tokens", "auth"),
194
+ ("email_verification_tokens", "auth"),
195
+ ("user_permissions", "rbac"),
196
+ ("role_permissions", "rbac"),
197
+ ("user_roles", "rbac"),
198
+ ("roles", "rbac"),
199
+ ("permissions", "rbac"),
200
+ ("users", "auth"),
201
+ ):
202
+ op.drop_table(table, schema=schema)
203
+ for schema in ("app", "rbac", "auth"):
204
+ op.execute(f"DROP SCHEMA IF EXISTS {schema}")
@@ -0,0 +1,35 @@
1
+ [alembic]
2
+ script_location = alembic
3
+ prepend_sys_path = .
4
+
5
+ [loggers]
6
+ keys = root,sqlalchemy,alembic
7
+
8
+ [handlers]
9
+ keys = console
10
+
11
+ [formatters]
12
+ keys = generic
13
+
14
+ [logger_root]
15
+ level = WARN
16
+ handlers = console
17
+
18
+ [logger_sqlalchemy]
19
+ level = WARN
20
+ handlers =
21
+ qualname = sqlalchemy.engine
22
+
23
+ [logger_alembic]
24
+ level = INFO
25
+ handlers = console
26
+ qualname = alembic
27
+
28
+ [handler_console]
29
+ class = StreamHandler
30
+ args = (sys.stderr,)
31
+ level = NOTSET
32
+ formatter = generic
33
+
34
+ [formatter_generic]
35
+ format = %(levelname)-5.5s [%(name)s] %(message)s
@@ -0,0 +1,34 @@
1
+ from fastapi import APIRouter
2
+
3
+ from app.api.v1.audit_logs.router import router as audit_logs_router
4
+ from app.api.v1.auth.router import router as auth_router
5
+ from app.api.v1.health.router import router as health_router
6
+ from app.api.v1.permissions.router import router as permissions_router
7
+ from app.api.v1.roles.router import router as roles_router
8
+ from app.api.v1.users.router import router as users_router
9
+
10
+ api_router = APIRouter(prefix="/api/v1")
11
+
12
+ API_ROUTES = (
13
+ (health_router, "/api/v1", ["Health"]),
14
+ (auth_router, "/api/v1/auth", ["Authentication"]),
15
+ (users_router, "/api/v1/users", ["Users"]),
16
+ (permissions_router, "/api/v1/permissions", ["Permissions"]),
17
+ (roles_router, "/api/v1/roles", ["Roles"]),
18
+ (audit_logs_router, "/api/v1/audit-logs", ["Audit Logs"]),
19
+ )
20
+
21
+
22
+ def register_api_routes(app) -> None:
23
+ for route, prefix, tags in API_ROUTES:
24
+ kwargs = {"prefix": prefix}
25
+ if tags is not None:
26
+ kwargs["tags"] = tags
27
+ app.include_router(route, **kwargs)
28
+
29
+
30
+ for route, prefix, tags in API_ROUTES:
31
+ relative_prefix = prefix.removeprefix("/api/v1")
32
+ api_router.include_router(route, prefix=relative_prefix, tags=tags)
33
+
34
+ router = api_router
@@ -0,0 +1,38 @@
1
+ from sqlalchemy import func, or_, select
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from app.db.models.audit_log import AuditLog
5
+ from app.helper.pagination_helper import apply_pagination
6
+
7
+
8
+ class AuditLogRepository:
9
+ def __init__(self, session: AsyncSession):
10
+ self.session = session
11
+
12
+ async def list(
13
+ self,
14
+ page: int,
15
+ page_size: int,
16
+ pagination: bool,
17
+ search: str | None = None,
18
+ ) -> tuple[list[AuditLog], int]:
19
+ query = select(AuditLog)
20
+ if search:
21
+ pattern = f"%{search.strip()}%"
22
+ query = query.where(
23
+ or_(
24
+ AuditLog.action.ilike(pattern),
25
+ AuditLog.resource.ilike(pattern),
26
+ AuditLog.user_email.ilike(pattern),
27
+ AuditLog.resource_id.ilike(pattern),
28
+ )
29
+ )
30
+ total = (
31
+ await self.session.execute(
32
+ select(func.count()).select_from(query.subquery())
33
+ )
34
+ ).scalar_one()
35
+ query = query.order_by(AuditLog.created_at.desc())
36
+ query = apply_pagination(query, page, page_size, pagination)
37
+ result = await self.session.execute(query)
38
+ return list(result.scalars().all()), total
@@ -0,0 +1,50 @@
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy.ext.asyncio import AsyncSession
3
+
4
+ from app.api.v1.audit_logs.repository import AuditLogRepository
5
+ from app.api.v1.audit_logs.schema import AuditLogResponse
6
+ from app.core.dependencies import get_async_db, require_any_permission
7
+ from app.core.responses import paginated_response, success_response
8
+ from app.helper.pagination_helper import PaginationParams
9
+
10
+ router = APIRouter()
11
+
12
+
13
+ @router.get(
14
+ "",
15
+ dependencies=[Depends(require_any_permission("admin:write", "audit_logs:read"))],
16
+ summary="List audit log entries",
17
+ )
18
+ async def list_audit_logs(
19
+ pagination: PaginationParams = Depends(),
20
+ db: AsyncSession = Depends(get_async_db),
21
+ ):
22
+ entries, total = await AuditLogRepository(db).list(
23
+ page=pagination.page,
24
+ page_size=pagination.page_size,
25
+ pagination=pagination.pagination,
26
+ search=pagination.search,
27
+ )
28
+ items = [
29
+ AuditLogResponse(
30
+ id=entry.id,
31
+ created_at=entry.created_at,
32
+ user_id=entry.user_id,
33
+ user_email=entry.user_email,
34
+ action=entry.action,
35
+ resource=entry.resource,
36
+ resource_id=entry.resource_id,
37
+ old_values=entry.old_values,
38
+ new_values=entry.new_values,
39
+ metadata=entry.log_metadata,
40
+ ip_address=entry.ip_address,
41
+ user_agent=entry.user_agent,
42
+ request_id=entry.request_id,
43
+ ).model_dump()
44
+ for entry in entries
45
+ ]
46
+ if not pagination.pagination:
47
+ return success_response(data=items)
48
+ return paginated_response(
49
+ items, total, pagination.page, pagination.page_size
50
+ )
@@ -0,0 +1,21 @@
1
+ from datetime import datetime
2
+ from typing import Any
3
+ from uuid import UUID
4
+
5
+ from app.utils.casing import CamelModel
6
+
7
+
8
+ class AuditLogResponse(CamelModel):
9
+ id: UUID
10
+ created_at: datetime
11
+ user_id: UUID | None = None
12
+ user_email: str | None = None
13
+ action: str
14
+ resource: str
15
+ resource_id: str | None = None
16
+ old_values: dict[str, Any] | None = None
17
+ new_values: dict[str, Any] | None = None
18
+ metadata: dict[str, Any] | None = None
19
+ ip_address: str | None = None
20
+ user_agent: str | None = None
21
+ request_id: str | None = None
@@ -0,0 +1,179 @@
1
+ from typing import Optional
2
+ from uuid import UUID
3
+ from datetime import datetime, timezone
4
+
5
+ from sqlalchemy import select, delete, exists
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from sqlalchemy.orm import selectinload
8
+
9
+ from app.db.models import (
10
+ EmailVerificationToken,
11
+ Role,
12
+ PasswordResetToken,
13
+ RevokedToken,
14
+ User,
15
+ UserSession,
16
+ )
17
+ from app.repositories.base import BaseRepository
18
+ from app.core.security import hash_opaque_token
19
+
20
+
21
+ class UserRepository(BaseRepository[User]):
22
+ def __init__(self, session: AsyncSession):
23
+ super().__init__(User, session)
24
+
25
+ async def get_by_email(self, email: str) -> Optional[User]:
26
+ q = (
27
+ select(User)
28
+ .where(User.email == email.lower(), User.is_deleted == False)
29
+ .options(
30
+ selectinload(User.roles).selectinload(Role.permissions),
31
+ selectinload(User.extra_permissions),
32
+ )
33
+ )
34
+ result = await self.session.execute(q)
35
+ return result.scalar_one_or_none()
36
+
37
+ async def get_by_username(self, username: str) -> Optional[User]:
38
+ q = select(User).where(
39
+ User.username == username.lower(), User.is_deleted == False
40
+ )
41
+ result = await self.session.execute(q)
42
+ return result.scalar_one_or_none()
43
+
44
+ async def get_with_permissions(self, user_id: UUID | str) -> Optional[User]:
45
+ """Load user with all permissions eagerly (for JWT construction)."""
46
+ if isinstance(user_id, str):
47
+ try:
48
+ user_id = UUID(user_id)
49
+ except ValueError:
50
+ return None
51
+
52
+ q = (
53
+ select(User)
54
+ .where(User.id == user_id, User.is_deleted == False)
55
+ .options(
56
+ selectinload(User.roles).selectinload(Role.permissions),
57
+ selectinload(User.extra_permissions),
58
+ )
59
+ )
60
+ result = await self.session.execute(q)
61
+ return result.scalar_one_or_none()
62
+
63
+ def collect_permissions(self, user: User) -> list[str]:
64
+ """Flatten role + individual permissions into a deduplicated list of codes."""
65
+ codes: set[str] = set()
66
+ role = self.get_role(user)
67
+ if role:
68
+ for perm in role.permissions:
69
+ if not perm.is_deleted and perm.is_active:
70
+ codes.add(perm.code)
71
+ for perm in user.extra_permissions:
72
+ if not perm.is_deleted and perm.is_active:
73
+ codes.add(perm.code)
74
+ if user.is_superuser or (role and role.name.upper() == "SUPER ADMIN"):
75
+ codes.add("*") # wildcard — superuser has everything
76
+ return sorted(codes)
77
+
78
+ def get_role(self, user: User) -> Role | None:
79
+ for role in user.roles:
80
+ if not role.is_deleted and role.is_active:
81
+ return role
82
+ return None
83
+
84
+ def collect_role_name(self, user: User) -> str | None:
85
+ role = self.get_role(user)
86
+ return role.name if role else None
87
+
88
+ class RevokedTokenRepository(BaseRepository[RevokedToken]):
89
+ def __init__(self, session: AsyncSession):
90
+ super().__init__(RevokedToken, session)
91
+
92
+ async def revoke(self, jti: str, token_type: str, expires_at: datetime) -> None:
93
+ if await self.is_revoked(jti):
94
+ return
95
+
96
+ self.session.add(
97
+ RevokedToken(jti=jti, token_type=token_type, expires_at=expires_at)
98
+ )
99
+ await self.session.flush()
100
+
101
+ async def is_revoked(self, jti: str) -> bool:
102
+ await self.delete_expired()
103
+ q = select(exists().where(RevokedToken.jti == jti))
104
+ result = await self.session.execute(q)
105
+ return bool(result.scalar())
106
+
107
+ async def delete_expired(self) -> None:
108
+ await self.session.execute(
109
+ delete(RevokedToken).where(
110
+ RevokedToken.expires_at <= datetime.now(timezone.utc)
111
+ )
112
+ )
113
+
114
+
115
+ class EmailVerificationTokenRepository(BaseRepository[EmailVerificationToken]):
116
+ def __init__(self, session: AsyncSession):
117
+ super().__init__(EmailVerificationToken, session)
118
+
119
+ async def get_valid(self, token: str) -> Optional[EmailVerificationToken]:
120
+ q = select(EmailVerificationToken).where(
121
+ EmailVerificationToken.token == hash_opaque_token(token),
122
+ EmailVerificationToken.used_at.is_(None),
123
+ EmailVerificationToken.expires_at > datetime.now(timezone.utc),
124
+ )
125
+ result = await self.session.execute(q)
126
+ return result.scalar_one_or_none()
127
+
128
+
129
+ class PasswordResetTokenRepository(BaseRepository[PasswordResetToken]):
130
+ def __init__(self, session: AsyncSession):
131
+ super().__init__(PasswordResetToken, session)
132
+
133
+ async def get_valid(self, token: str) -> Optional[PasswordResetToken]:
134
+ q = select(PasswordResetToken).where(
135
+ PasswordResetToken.token == hash_opaque_token(token),
136
+ PasswordResetToken.used_at.is_(None),
137
+ PasswordResetToken.expires_at > datetime.now(timezone.utc),
138
+ )
139
+ result = await self.session.execute(q)
140
+ return result.scalar_one_or_none()
141
+
142
+
143
+ class UserSessionRepository(BaseRepository[UserSession]):
144
+ def __init__(self, session: AsyncSession):
145
+ super().__init__(UserSession, session)
146
+
147
+ async def get_active_by_refresh_jti(self, jti: str) -> Optional[UserSession]:
148
+ q = select(UserSession).where(
149
+ UserSession.refresh_jti == jti,
150
+ UserSession.revoked_at.is_(None),
151
+ UserSession.expires_at > datetime.now(timezone.utc),
152
+ )
153
+ result = await self.session.execute(q)
154
+ return result.scalar_one_or_none()
155
+
156
+ async def get_by_previous_refresh_jti(self, jti: str) -> Optional[UserSession]:
157
+ q = select(UserSession).where(UserSession.previous_refresh_jti == jti)
158
+ result = await self.session.execute(q)
159
+ return result.scalar_one_or_none()
160
+
161
+ async def list_active_for_user(self, user_id: UUID | str) -> list[UserSession]:
162
+ if isinstance(user_id, str):
163
+ user_id = UUID(user_id)
164
+ q = (
165
+ select(UserSession)
166
+ .where(
167
+ UserSession.user_id == user_id,
168
+ UserSession.revoked_at.is_(None),
169
+ UserSession.expires_at > datetime.now(timezone.utc),
170
+ )
171
+ .order_by(UserSession.created_at.desc())
172
+ )
173
+ result = await self.session.execute(q)
174
+ return list(result.scalars().all())
175
+
176
+ async def revoke_session(self, session: UserSession) -> None:
177
+ session.revoked_at = datetime.now(timezone.utc)
178
+ self.session.add(session)
179
+ await self.session.flush()