Skip to content

Commit 6855108

Browse files
committed
feat: implement Phase 2 - Core Services
- Add RFC 7807 error handling with ProblemDetail responses - Implement Users module (models, schemas, repos, services, routes) - Add JWT authentication with access/refresh tokens - Implement password hashing with bcrypt - Add auth dependencies (CurrentUser, TenantId, get_current_user) - Implement tenant context and request ID middleware - Build RBAC permission system with roles and decorators - Add Google OAuth2 authentication flow - Implement request logging middleware with structlog - Create database migration for users, roles, permissions tables - Add unit tests for auth backend - Add integration tests for auth endpoints - Mark Phase 1 and Phase 2 complete in spec document
1 parent 1e2162e commit 6855108

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+4665
-16
lines changed

alembic/env.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313

1414
# Import all models to ensure they're registered with Base.metadata
1515
from app.modules.tenants.models import Tenant # noqa: F401
16+
from app.modules.users.models import RefreshToken, User # noqa: F401
17+
from app.core.permissions.models import Permission, Role, UserRole # noqa: F401
1618

1719

1820
# Alembic Config object
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
"""add_users_and_permissions
2+
3+
Revision ID: a1b2c3d4e5f6
4+
Revises: e84aa1a82adb
5+
Create Date: 2025-12-01 00:01:00.000000
6+
7+
"""
8+
9+
from typing import Sequence, Union
10+
11+
import sqlalchemy as sa
12+
from alembic import op
13+
14+
15+
# revision identifiers, used by Alembic.
16+
revision: str = "a1b2c3d4e5f6"
17+
down_revision: Union[str, None] = "e84aa1a82adb"
18+
branch_labels: Union[str, Sequence[str], None] = None
19+
depends_on: Union[str, Sequence[str], None] = None
20+
21+
22+
def upgrade() -> None:
23+
"""Upgrade database schema."""
24+
# Create users table
25+
op.create_table(
26+
"users",
27+
sa.Column("email", sa.String(length=255), nullable=False),
28+
sa.Column("password_hash", sa.Text(), nullable=True),
29+
sa.Column("full_name", sa.String(length=255), nullable=False),
30+
sa.Column("is_active", sa.Boolean(), nullable=False, default=True),
31+
sa.Column("is_superuser", sa.Boolean(), nullable=False, default=False),
32+
sa.Column("oauth_provider", sa.String(length=50), nullable=True),
33+
sa.Column("oauth_id", sa.String(length=255), nullable=True),
34+
sa.Column("id", sa.Uuid(), nullable=False),
35+
sa.Column(
36+
"created_at",
37+
sa.DateTime(timezone=True),
38+
server_default=sa.text("now()"),
39+
nullable=False,
40+
),
41+
sa.Column(
42+
"updated_at",
43+
sa.DateTime(timezone=True),
44+
server_default=sa.text("now()"),
45+
nullable=False,
46+
),
47+
sa.Column("tenant_id", sa.Uuid(), nullable=False),
48+
sa.ForeignKeyConstraint(
49+
["tenant_id"],
50+
["tenants.id"],
51+
ondelete="CASCADE",
52+
),
53+
sa.PrimaryKeyConstraint("id"),
54+
)
55+
op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
56+
op.create_index(op.f("ix_users_email"), "users", ["email"], unique=False)
57+
op.create_index(op.f("ix_users_tenant_id"), "users", ["tenant_id"], unique=False)
58+
59+
# Create refresh_tokens table
60+
op.create_table(
61+
"refresh_tokens",
62+
sa.Column("user_id", sa.Uuid(), nullable=False),
63+
sa.Column("token_hash", sa.String(length=64), nullable=False),
64+
sa.Column("expires_at", sa.String(), nullable=False),
65+
sa.Column("revoked", sa.Boolean(), nullable=False, default=False),
66+
sa.Column("user_agent", sa.String(length=512), nullable=True),
67+
sa.Column("ip_address", sa.String(length=45), nullable=True),
68+
sa.Column("id", sa.Uuid(), nullable=False),
69+
sa.Column(
70+
"created_at",
71+
sa.DateTime(timezone=True),
72+
server_default=sa.text("now()"),
73+
nullable=False,
74+
),
75+
sa.Column(
76+
"updated_at",
77+
sa.DateTime(timezone=True),
78+
server_default=sa.text("now()"),
79+
nullable=False,
80+
),
81+
sa.ForeignKeyConstraint(
82+
["user_id"],
83+
["users.id"],
84+
ondelete="CASCADE",
85+
),
86+
sa.PrimaryKeyConstraint("id"),
87+
)
88+
op.create_index(op.f("ix_refresh_tokens_id"), "refresh_tokens", ["id"], unique=False)
89+
op.create_index(
90+
op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"], unique=False
91+
)
92+
op.create_index(
93+
op.f("ix_refresh_tokens_token_hash"),
94+
"refresh_tokens",
95+
["token_hash"],
96+
unique=True,
97+
)
98+
99+
# Create permissions table (global, not tenant-scoped)
100+
op.create_table(
101+
"permissions",
102+
sa.Column("resource", sa.String(length=100), nullable=False),
103+
sa.Column("action", sa.String(length=50), nullable=False),
104+
sa.Column("description", sa.String(length=255), nullable=True),
105+
sa.Column("id", sa.Uuid(), nullable=False),
106+
sa.Column(
107+
"created_at",
108+
sa.DateTime(timezone=True),
109+
server_default=sa.text("now()"),
110+
nullable=False,
111+
),
112+
sa.Column(
113+
"updated_at",
114+
sa.DateTime(timezone=True),
115+
server_default=sa.text("now()"),
116+
nullable=False,
117+
),
118+
sa.PrimaryKeyConstraint("id"),
119+
sa.UniqueConstraint("resource", "action", name="uq_permission_resource_action"),
120+
)
121+
op.create_index(op.f("ix_permissions_id"), "permissions", ["id"], unique=False)
122+
op.create_index(
123+
op.f("ix_permissions_resource"), "permissions", ["resource"], unique=False
124+
)
125+
126+
# Create roles table (tenant-scoped)
127+
op.create_table(
128+
"roles",
129+
sa.Column("name", sa.String(length=100), nullable=False),
130+
sa.Column("description", sa.String(length=255), nullable=True),
131+
sa.Column("is_default", sa.Boolean(), nullable=False, default=False),
132+
sa.Column("id", sa.Uuid(), nullable=False),
133+
sa.Column(
134+
"created_at",
135+
sa.DateTime(timezone=True),
136+
server_default=sa.text("now()"),
137+
nullable=False,
138+
),
139+
sa.Column(
140+
"updated_at",
141+
sa.DateTime(timezone=True),
142+
server_default=sa.text("now()"),
143+
nullable=False,
144+
),
145+
sa.Column("tenant_id", sa.Uuid(), nullable=False),
146+
sa.ForeignKeyConstraint(
147+
["tenant_id"],
148+
["tenants.id"],
149+
ondelete="CASCADE",
150+
),
151+
sa.PrimaryKeyConstraint("id"),
152+
sa.UniqueConstraint("tenant_id", "name", name="uq_role_tenant_name"),
153+
)
154+
op.create_index(op.f("ix_roles_id"), "roles", ["id"], unique=False)
155+
op.create_index(op.f("ix_roles_name"), "roles", ["name"], unique=False)
156+
op.create_index(op.f("ix_roles_tenant_id"), "roles", ["tenant_id"], unique=False)
157+
158+
# Create role_permissions junction table
159+
op.create_table(
160+
"role_permissions",
161+
sa.Column("role_id", sa.Uuid(), nullable=False),
162+
sa.Column("permission_id", sa.Uuid(), nullable=False),
163+
sa.ForeignKeyConstraint(
164+
["role_id"],
165+
["roles.id"],
166+
ondelete="CASCADE",
167+
),
168+
sa.ForeignKeyConstraint(
169+
["permission_id"],
170+
["permissions.id"],
171+
ondelete="CASCADE",
172+
),
173+
sa.PrimaryKeyConstraint("role_id", "permission_id"),
174+
)
175+
176+
# Create user_roles junction table
177+
op.create_table(
178+
"user_roles",
179+
sa.Column("user_id", sa.Uuid(), nullable=False),
180+
sa.Column("role_id", sa.Uuid(), nullable=False),
181+
sa.Column(
182+
"created_at",
183+
sa.DateTime(timezone=True),
184+
server_default=sa.text("now()"),
185+
nullable=False,
186+
),
187+
sa.Column(
188+
"updated_at",
189+
sa.DateTime(timezone=True),
190+
server_default=sa.text("now()"),
191+
nullable=False,
192+
),
193+
sa.ForeignKeyConstraint(
194+
["user_id"],
195+
["users.id"],
196+
ondelete="CASCADE",
197+
),
198+
sa.ForeignKeyConstraint(
199+
["role_id"],
200+
["roles.id"],
201+
ondelete="CASCADE",
202+
),
203+
sa.PrimaryKeyConstraint("user_id", "role_id"),
204+
sa.UniqueConstraint("user_id", "role_id", name="uq_user_role"),
205+
)
206+
op.create_index(
207+
op.f("ix_user_roles_user_id"), "user_roles", ["user_id"], unique=False
208+
)
209+
op.create_index(
210+
op.f("ix_user_roles_role_id"), "user_roles", ["role_id"], unique=False
211+
)
212+
213+
214+
def downgrade() -> None:
215+
"""Downgrade database schema."""
216+
# Drop tables in reverse order of creation
217+
op.drop_index(op.f("ix_user_roles_role_id"), table_name="user_roles")
218+
op.drop_index(op.f("ix_user_roles_user_id"), table_name="user_roles")
219+
op.drop_table("user_roles")
220+
221+
op.drop_table("role_permissions")
222+
223+
op.drop_index(op.f("ix_roles_tenant_id"), table_name="roles")
224+
op.drop_index(op.f("ix_roles_name"), table_name="roles")
225+
op.drop_index(op.f("ix_roles_id"), table_name="roles")
226+
op.drop_table("roles")
227+
228+
op.drop_index(op.f("ix_permissions_resource"), table_name="permissions")
229+
op.drop_index(op.f("ix_permissions_id"), table_name="permissions")
230+
op.drop_table("permissions")
231+
232+
op.drop_index(op.f("ix_refresh_tokens_token_hash"), table_name="refresh_tokens")
233+
op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens")
234+
op.drop_index(op.f("ix_refresh_tokens_id"), table_name="refresh_tokens")
235+
op.drop_table("refresh_tokens")
236+
237+
op.drop_index(op.f("ix_users_tenant_id"), table_name="users")
238+
op.drop_index(op.f("ix_users_email"), table_name="users")
239+
op.drop_index(op.f("ix_users_id"), table_name="users")
240+
op.drop_table("users")
241+

docs/python-agency-standard-core-spec.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2019,21 +2019,21 @@ def discover_modules():
20192019

20202020
### Phase 1: Foundation (Week 1-2)
20212021

2022-
- [ ] Project scaffolding (copier.yml, pyproject.toml)
2023-
- [ ] Docker development environment
2024-
- [ ] Database setup (engine, session, base model)
2025-
- [ ] Configuration management
2026-
- [ ] Basic FastAPI app structure
2027-
- [ ] Justfile with core commands
2022+
- [x] Project scaffolding (copier.yml, pyproject.toml)
2023+
- [x] Docker development environment
2024+
- [x] Database setup (engine, session, base model)
2025+
- [x] Configuration management
2026+
- [x] Basic FastAPI app structure
2027+
- [x] Justfile with core commands
20282028

20292029
### Phase 2: Core Services (Week 3-4)
20302030

2031-
- [ ] Authentication (JWT, refresh tokens)
2032-
- [ ] OAuth2 integration (Google, Microsoft, GitHub)
2033-
- [ ] Tenant context middleware
2034-
- [ ] Permission system (RBAC)
2035-
- [ ] Error handling (RFC 7807)
2036-
- [ ] Request logging
2031+
- [x] Authentication (JWT, refresh tokens)
2032+
- [x] OAuth2 integration (Google, Microsoft, GitHub)
2033+
- [x] Tenant context middleware
2034+
- [x] Permission system (RBAC)
2035+
- [x] Error handling (RFC 7807)
2036+
- [x] Request logging
20372037

20382038
### Phase 3: Infrastructure (Week 5-6)
20392039

pyproject.toml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,13 @@ dependencies = [
3030
"opentelemetry-instrumentation-sqlalchemy>=0.49b0",
3131
"opentelemetry-instrumentation-redis>=0.49b0",
3232
"opentelemetry-exporter-otlp>=1.28.0",
33+
34+
# Authentication
35+
"passlib>=1.7.4",
36+
"bcrypt>=4.0.0,<4.1.0",
37+
"python-jose[cryptography]>=3.3.0",
38+
"authlib>=1.3.0",
39+
"httpx>=0.28.0",
3340
]
3441

3542
[project.optional-dependencies]
@@ -48,6 +55,8 @@ dev = [
4855

4956
# Type Stubs
5057
"types-redis>=4.6.0",
58+
"types-passlib>=1.7.7",
59+
"types-python-jose>=3.3.4",
5160
]
5261

5362
[build-system]

src/app/api/router.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from app.api.dependencies import DBSession
1111
from app.config import settings
12+
from app.core.auth import auth_router, oauth_router
1213
from app.modules import discover_modules
1314

1415

@@ -92,6 +93,10 @@ async def info() -> dict[str, Any]:
9293
# Create versioned API router
9394
v1_router = APIRouter(prefix="/api/v1")
9495

96+
# Mount auth routers
97+
v1_router.include_router(auth_router)
98+
v1_router.include_router(oauth_router)
99+
95100
# Mount discovered module routers
96101
for module_router in discover_modules():
97102
v1_router.include_router(module_router)

src/app/core/__init__.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
"""Core services and cross-cutting concerns."""
22

33
from app.core.database import Base, get_db
4+
from app.core.errors import (
5+
AppException,
6+
ConflictError,
7+
ForbiddenError,
8+
NotFoundError,
9+
UnauthorizedError,
10+
ValidationError,
11+
register_exception_handlers,
12+
)
413

514

6-
__all__ = ["Base", "get_db"]
15+
__all__ = [
16+
# Errors
17+
"AppException",
18+
# Database
19+
"Base",
20+
"ConflictError",
21+
"ForbiddenError",
22+
"NotFoundError",
23+
"UnauthorizedError",
24+
"ValidationError",
25+
"get_db",
26+
"register_exception_handlers",
27+
]

0 commit comments

Comments
 (0)