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.
- fastapi_forge/__init__.py +7 -0
- fastapi_forge/__main__.py +6 -0
- fastapi_forge/cli.py +211 -0
- fastapi_forge/templates/with_rbac/Dockerfile +31 -0
- fastapi_forge/templates/with_rbac/README.md +121 -0
- fastapi_forge/templates/with_rbac/_dockerignore +16 -0
- fastapi_forge/templates/with_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/with_rbac/_gitignore +19 -0
- fastapi_forge/templates/with_rbac/alembic/README +1 -0
- fastapi_forge/templates/with_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/with_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/with_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +204 -0
- fastapi_forge/templates/with_rbac/alembic.ini +35 -0
- fastapi_forge/templates/with_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/api.py +34 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/router.py +50 -0
- fastapi_forge/templates/with_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/repository.py +179 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/router.py +209 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/schema.py +98 -0
- fastapi_forge/templates/with_rbac/app/api/v1/auth/service.py +383 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/with_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/__init__.py +0 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/repository.py +80 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/router.py +151 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/schema.py +40 -0
- fastapi_forge/templates/with_rbac/app/api/v1/permissions/service.py +156 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/repository.py +161 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/router.py +169 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/schema.py +51 -0
- fastapi_forge/templates/with_rbac/app/api/v1/roles/service.py +319 -0
- fastapi_forge/templates/with_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/repository.py +181 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/router.py +146 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/schema.py +112 -0
- fastapi_forge/templates/with_rbac/app/api/v1/users/service.py +291 -0
- fastapi_forge/templates/with_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/dependencies.py +131 -0
- fastapi_forge/templates/with_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/with_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/with_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/with_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/with_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/with_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/with_rbac/app/db/models/__init__.py +16 -0
- fastapi_forge/templates/with_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/with_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/with_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/with_rbac/app/db/models/permission.py +174 -0
- fastapi_forge/templates/with_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/with_rbac/app/db/models/user.py +53 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/with_rbac/app/db/schemas/names.py +9 -0
- fastapi_forge/templates/with_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/with_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/with_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/with_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/with_rbac/app/main.py +66 -0
- fastapi_forge/templates/with_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/with_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/with_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/with_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/with_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/with_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/with_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/with_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/with_rbac/compose.yaml +33 -0
- fastapi_forge/templates/with_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/with_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/with_rbac/requirements.txt +16 -0
- fastapi_forge/templates/with_rbac/sample.env +42 -0
- fastapi_forge/templates/with_rbac/scripts/seed_first_user.py +166 -0
- fastapi_forge/templates/with_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/with_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/with_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/with_rbac/tests/test_permissions.py +36 -0
- fastapi_forge/templates/with_rbac/tests/test_security.py +68 -0
- fastapi_forge/templates/without_rbac/Dockerfile +31 -0
- fastapi_forge/templates/without_rbac/README.md +106 -0
- fastapi_forge/templates/without_rbac/_dockerignore +16 -0
- fastapi_forge/templates/without_rbac/_github/workflows/ci.yml +23 -0
- fastapi_forge/templates/without_rbac/_gitignore +19 -0
- fastapi_forge/templates/without_rbac/alembic/README +1 -0
- fastapi_forge/templates/without_rbac/alembic/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/alembic/env.py +51 -0
- fastapi_forge/templates/without_rbac/alembic/script.py.mako +28 -0
- fastapi_forge/templates/without_rbac/alembic/versions/2255ba4f9604_fresh_baseline.py +125 -0
- fastapi_forge/templates/without_rbac/alembic.ini +35 -0
- fastapi_forge/templates/without_rbac/app/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/api.py +29 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/repository.py +38 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/router.py +45 -0
- fastapi_forge/templates/without_rbac/app/api/v1/audit_logs/schema.py +21 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/__init__.py +0 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/repository.py +127 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/router.py +207 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/schema.py +96 -0
- fastapi_forge/templates/without_rbac/app/api/v1/auth/service.py +373 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/__init__.py +3 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/router.py +23 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/schema.py +5 -0
- fastapi_forge/templates/without_rbac/app/api/v1/health/service.py +25 -0
- fastapi_forge/templates/without_rbac/app/api/v1/schema.py +7 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/repository.py +69 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/router.py +94 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/schema.py +50 -0
- fastapi_forge/templates/without_rbac/app/api/v1/users/service.py +92 -0
- fastapi_forge/templates/without_rbac/app/core/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/core/config.py +131 -0
- fastapi_forge/templates/without_rbac/app/core/dependencies.py +71 -0
- fastapi_forge/templates/without_rbac/app/core/exceptions.py +162 -0
- fastapi_forge/templates/without_rbac/app/core/logging.py +231 -0
- fastapi_forge/templates/without_rbac/app/core/middleware.py +188 -0
- fastapi_forge/templates/without_rbac/app/core/responses.py +108 -0
- fastapi_forge/templates/without_rbac/app/core/security.py +115 -0
- fastapi_forge/templates/without_rbac/app/db/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/db/base.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/models/__init__.py +10 -0
- fastapi_forge/templates/without_rbac/app/db/models/audit_log.py +58 -0
- fastapi_forge/templates/without_rbac/app/db/models/auth_token.py +72 -0
- fastapi_forge/templates/without_rbac/app/db/models/notification.py +49 -0
- fastapi_forge/templates/without_rbac/app/db/models/revoked_token.py +21 -0
- fastapi_forge/templates/without_rbac/app/db/models/user.py +33 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/__init__.py +8 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/common.py +70 -0
- fastapi_forge/templates/without_rbac/app/db/schemas/names.py +5 -0
- fastapi_forge/templates/without_rbac/app/db/session.py +86 -0
- fastapi_forge/templates/without_rbac/app/helper/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/helper/pagination_helper.py +44 -0
- fastapi_forge/templates/without_rbac/app/helper/search.py +51 -0
- fastapi_forge/templates/without_rbac/app/helper/sorting.py +77 -0
- fastapi_forge/templates/without_rbac/app/main.py +66 -0
- fastapi_forge/templates/without_rbac/app/repositories/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/repositories/base.py +347 -0
- fastapi_forge/templates/without_rbac/app/services/__init__.py +1 -0
- fastapi_forge/templates/without_rbac/app/services/audit.py +58 -0
- fastapi_forge/templates/without_rbac/app/services/email.py +118 -0
- fastapi_forge/templates/without_rbac/app/services/notification.py +82 -0
- fastapi_forge/templates/without_rbac/app/templates/email/notification.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/password_reset.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/verify_email.html +7 -0
- fastapi_forge/templates/without_rbac/app/templates/email/welcome.html +6 -0
- fastapi_forge/templates/without_rbac/app/utils/casing.py +31 -0
- fastapi_forge/templates/without_rbac/compose.yaml +33 -0
- fastapi_forge/templates/without_rbac/pyproject.toml +14 -0
- fastapi_forge/templates/without_rbac/requirements-dev.txt +5 -0
- fastapi_forge/templates/without_rbac/requirements.txt +16 -0
- fastapi_forge/templates/without_rbac/sample.env +42 -0
- fastapi_forge/templates/without_rbac/scripts/seed_first_user.py +51 -0
- fastapi_forge/templates/without_rbac/tests/test_audit.py +42 -0
- fastapi_forge/templates/without_rbac/tests/test_config.py +27 -0
- fastapi_forge/templates/without_rbac/tests/test_generator.py +20 -0
- fastapi_forge/templates/without_rbac/tests/test_security.py +68 -0
- fastapi_forge_cli-0.1.0.dist-info/METADATA +225 -0
- fastapi_forge_cli-0.1.0.dist-info/RECORD +181 -0
- fastapi_forge_cli-0.1.0.dist-info/WHEEL +5 -0
- fastapi_forge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- fastapi_forge_cli-0.1.0.dist-info/licenses/LICENSE +18 -0
- 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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|
|
File without changes
|
|
@@ -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()
|