Skip to content

Commit 93db190

Browse files
authored
Merge pull request #90 from MyElectricalData/develop
Develop
2 parents 7e0c0ed + bd9875f commit 93db190

File tree

20 files changed

+1341
-152
lines changed

20 files changed

+1341
-152
lines changed

CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## [1.4.0-dev.2](https://github.com/MyElectricalData/myelectricaldata_new/compare/1.4.0-dev.1...1.4.0-dev.2) (2025-12-20)
4+
5+
### Bug Fixes
6+
7+
* address Copilot review suggestions ([f78115a](https://github.com/MyElectricalData/myelectricaldata_new/commit/f78115a8dbed44f5a03fd410f8be5da01b2554db))
8+
9+
## [1.4.0-dev.1](https://github.com/MyElectricalData/myelectricaldata_new/compare/1.3.0...1.4.0-dev.1) (2025-12-20)
10+
11+
### Features
12+
13+
* add admin data sharing for PDL debugging ([cfb32b8](https://github.com/MyElectricalData/myelectricaldata_new/commit/cfb32b83ddb435c9c792aec031ead2ad2ae054ab))
14+
15+
### Bug Fixes
16+
17+
* **api:** correct type annotation for cached_data in admin router ([9b383f1](https://github.com/MyElectricalData/myelectricaldata_new/commit/9b383f13dade4ea6332254165e075837c17fd26b))
18+
319
## [1.3.0](https://github.com/MyElectricalData/myelectricaldata_new/compare/1.2.0...1.3.0) (2025-12-20)
420

521
### Features
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
"""add admin_data_sharing to users
2+
3+
Revision ID: 007
4+
Revises: 006
5+
Create Date: 2025-12-20
6+
7+
"""
8+
from typing import Sequence, Union
9+
10+
from alembic import op
11+
import sqlalchemy as sa
12+
13+
14+
# revision identifiers, used by Alembic.
15+
revision: str = '007'
16+
down_revision: Union[str, None] = '006'
17+
branch_labels: Union[str, Sequence[str], None] = None
18+
depends_on: Union[str, Sequence[str], None] = None
19+
20+
21+
def upgrade() -> None:
22+
# Add admin_data_sharing columns to users table
23+
op.add_column('users', sa.Column('admin_data_sharing', sa.Boolean(), nullable=False, server_default='false'))
24+
op.add_column('users', sa.Column('admin_data_sharing_enabled_at', sa.DateTime(timezone=True), nullable=True))
25+
26+
27+
def downgrade() -> None:
28+
# Remove admin_data_sharing columns from users table
29+
op.drop_column('users', 'admin_data_sharing_enabled_at')
30+
op.drop_column('users', 'admin_data_sharing')

apps/api/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "myelectricaldata-api"
3-
version = "1.3.0"
3+
version = "1.4.0-dev.2"
44
description = "MyElectricalData API Gateway for Enedis data"
55
authors = [{name = "m4dm4rtig4n"}]
66
license = {text = "Apache-2.0"}

apps/api/src/middleware/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
from .auth import get_current_user, require_not_demo, is_demo_user, DEMO_EMAIL
1+
from .auth import (
2+
get_current_user,
3+
require_not_demo,
4+
is_demo_user,
5+
DEMO_EMAIL,
6+
get_impersonation_context,
7+
get_encryption_key,
8+
)
29
from .admin import require_admin, require_permission, require_action
310

411
__all__ = [
@@ -9,4 +16,6 @@
916
"require_not_demo",
1017
"is_demo_user",
1118
"DEMO_EMAIL",
19+
"get_impersonation_context",
20+
"get_encryption_key",
1221
]

apps/api/src/middleware/auth.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,61 @@ async def require_not_demo(current_user: User = Depends(get_current_user)) -> Us
158158
detail="Le compte de démonstration est en lecture seule"
159159
)
160160
return current_user
161+
162+
163+
async def get_impersonation_context(
164+
request: Request,
165+
current_user: User = Depends(get_current_user),
166+
db: AsyncSession = Depends(get_db)
167+
) -> Optional[User]:
168+
"""
169+
Get the user being impersonated if admin is accessing shared data.
170+
171+
Returns:
172+
- The impersonated user (for getting their client_secret) if:
173+
1. Current user is admin (is_admin or role admin)
174+
2. X-Impersonate-User-Id header is present
175+
3. Target user has enabled admin_data_sharing
176+
- None otherwise (use current_user's client_secret)
177+
178+
This is used to properly decrypt cached data that was encrypted
179+
with the data owner's client_secret.
180+
"""
181+
impersonate_user_id = request.headers.get("X-Impersonate-User-Id")
182+
183+
if not impersonate_user_id:
184+
return None
185+
186+
# Check if current user is admin
187+
is_admin = current_user.is_admin or (current_user.role and current_user.role.name == "admin")
188+
if not is_admin:
189+
logger.warning(f"[IMPERSONATION] Non-admin user {current_user.email} tried to impersonate {impersonate_user_id}")
190+
return None
191+
192+
# Get the target user
193+
result = await db.execute(select(User).where(User.id == impersonate_user_id))
194+
target_user = result.scalar_one_or_none()
195+
196+
if not target_user:
197+
logger.warning(f"[IMPERSONATION] Admin {current_user.email} tried to impersonate non-existent user {impersonate_user_id}")
198+
return None
199+
200+
# Check if target user has enabled data sharing
201+
if not target_user.admin_data_sharing:
202+
logger.warning(f"[IMPERSONATION] Admin {current_user.email} tried to impersonate user {target_user.email} who has not enabled data sharing")
203+
return None
204+
205+
logger.info(f"[IMPERSONATION] Admin {current_user.email} impersonating user {target_user.email}")
206+
return target_user
207+
208+
209+
def get_encryption_key(current_user: User, impersonated_user: Optional[User]) -> str:
210+
"""
211+
Get the encryption key (client_secret) to use for cache operations.
212+
213+
Uses the impersonated user's key if impersonation is active,
214+
otherwise uses the current user's key.
215+
"""
216+
if impersonated_user:
217+
return impersonated_user.client_secret
218+
return current_user.client_secret

apps/api/src/models/user.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
from __future__ import annotations
22
import uuid
3+
from datetime import datetime
34
from typing import TYPE_CHECKING
4-
from sqlalchemy import String, Boolean, ForeignKey
5+
from sqlalchemy import String, Boolean, ForeignKey, DateTime
56
from sqlalchemy.orm import Mapped, mapped_column, relationship
67
from .base import Base, TimestampMixin
78

@@ -23,6 +24,8 @@ class User(Base, TimestampMixin):
2324
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # Kept for backward compatibility
2425
email_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
2526
debug_mode: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
27+
admin_data_sharing: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
28+
admin_data_sharing_enabled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
2629
enedis_customer_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
2730
role_id: Mapped[str | None] = mapped_column(String(36), ForeignKey("roles.id"), nullable=True)
2831

apps/api/src/routers/accounts.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,11 +276,13 @@ async def get_current_user_info(
276276
created_at=user.created_at,
277277
)
278278

279-
# Add is_admin field, debug_mode, and role
279+
# Add is_admin field, debug_mode, admin_data_sharing, and role
280280
# is_admin is true if: database flag OR email in ADMIN_EMAILS env var
281281
user_data = user_response.model_dump()
282282
user_data['is_admin'] = user.is_admin or settings.is_admin(user.email)
283283
user_data['debug_mode'] = user.debug_mode
284+
user_data['admin_data_sharing'] = user.admin_data_sharing
285+
user_data['admin_data_sharing_enabled_at'] = user.admin_data_sharing_enabled_at.isoformat() if user.admin_data_sharing_enabled_at else None
284286

285287
# Add role information with permissions
286288
if user.role:
@@ -592,6 +594,38 @@ async def reset_password(request: Request, db: AsyncSession = Depends(get_db)) -
592594
return APIResponse(success=True, data={"message": "Password reset successfully!"})
593595

594596

597+
@router.post("/toggle-admin-sharing", response_model=APIResponse)
598+
async def toggle_admin_data_sharing(
599+
current_user: User = Depends(require_not_demo),
600+
db: AsyncSession = Depends(get_db)
601+
) -> APIResponse:
602+
"""Toggle admin data sharing for current user.
603+
604+
When enabled, administrators can access the user's PDL data and cache
605+
for debugging purposes. The user can revoke this access at any time.
606+
"""
607+
current_user.admin_data_sharing = not current_user.admin_data_sharing
608+
609+
if current_user.admin_data_sharing:
610+
current_user.admin_data_sharing_enabled_at = datetime.now(UTC)
611+
else:
612+
current_user.admin_data_sharing_enabled_at = None
613+
614+
await db.commit()
615+
616+
action = "enabled" if current_user.admin_data_sharing else "disabled"
617+
logger.info(f"[ADMIN_SHARING] User {current_user.email} {action} admin data sharing")
618+
619+
return APIResponse(
620+
success=True,
621+
data={
622+
"admin_data_sharing": current_user.admin_data_sharing,
623+
"admin_data_sharing_enabled_at": current_user.admin_data_sharing_enabled_at.isoformat() if current_user.admin_data_sharing_enabled_at else None,
624+
"message": f"Partage des données avec les administrateurs {'activé' if current_user.admin_data_sharing else 'désactivé'}"
625+
}
626+
)
627+
628+
595629
@router.post("/update-password", response_model=APIResponse)
596630
async def update_password(
597631
request: Request,

0 commit comments

Comments
 (0)