Skip to content

Commit cfb32b8

Browse files
Clément VALENTINclaude
andcommitted
feat: add admin data sharing for PDL debugging
Allow users to share their PDL data with administrators to facilitate debugging and technical support. Admins can view shared PDLs in the header selector and access cached data using the owner's encryption key. Backend: - Add admin_data_sharing fields to User model with migration - Add toggle-admin-sharing endpoint in accounts router - Add shared PDL endpoints in admin router (list, cache access) - Add impersonation middleware (get_impersonation_context, get_encryption_key) - Modify all Enedis endpoints to support admin impersonation Frontend: - Add sharing toggle section in Settings page - Add sharing indicator column in AdminUsers table - Extend PDL selector with shared PDLs (optgroup separation) - Add impersonation context to pdlStore with persistence - Add X-Impersonate-User-Id header in API client 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 5a437c9 commit cfb32b8

File tree

15 files changed

+1190
-148
lines changed

15 files changed

+1190
-148
lines changed
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/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 donnees avec les administrateurs {'active' if current_user.admin_data_sharing else 'desactive'}"
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)