Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
081fc82
chore: save tests_backup locally
ademboukabes Jun 23, 2026
83138be
test(core): consolidate test suite, fix E2E auth flow, and update AI …
ademboukabes Jul 1, 2026
c9a7338
style: auto-fix ruff linting errors
ademboukabes Jul 1, 2026
f070bda
test(integration): fix mock for face embedding stream
ademboukabes Jul 1, 2026
205c7cb
style: fix remaining manual ruff errors
ademboukabes Jul 1, 2026
e8e45fa
chore: exclude tests and scripts from mypy
ademboukabes Jul 1, 2026
c697829
fix: pinned compose project name in mobile quick start
Tyjfre-j Jul 2, 2026
a62af52
fix: return user display on user/auth:me
Tyjfre-j Jul 3, 2026
3401071
feat(users): add avatar_key column and UpdateUserAvatar query
Tyjfre-j Jul 3, 2026
029e12a
refactor(images): extract shared image validation from enrollment
Tyjfre-j Jul 3, 2026
7f67c46
feat: added avatar services
Tyjfre-j Jul 3, 2026
275f937
feat(users): add avatar upload/serve endpoints
Tyjfre-j Jul 3, 2026
1219010
refactor(audit): decouple AuditActorSchema from UserSchema
Tyjfre-j Jul 3, 2026
275a281
ci: add docker build test to workflow
ademboukabes Jul 3, 2026
46e7021
Fix PR review bugs and failing tests
ademboukabes Jul 4, 2026
a46f47f
style: fix ruff whitespace warnings
ademboukabes Jul 4, 2026
c496768
ci: add automated tests job with postgres and redis
ademboukabes Jul 4, 2026
d83498a
fix: add missing pydantic env vars to ci
ademboukabes Jul 4, 2026
b939602
fix: skip E2E test_stats_endpoint in CI to prevent NATS timeout
ademboukabes Jul 4, 2026
f693d66
fix: add NATS and MinIO to CI services to prevent FastAPI lifespan ti…
ademboukabes Jul 4, 2026
c51c8f1
fix: removed dup imports
Tyjfre-j Jul 4, 2026
ac7ec04
fix: fixed stuff_user and evenParticipant naming issues
Tyjfre-j Jul 4, 2026
b0e3d14
fix(tests): scope sys.modules mocking in test_photo_worker.py so it d…
Tyjfre-j Jul 4, 2026
d4431aa
fix(redis): reset RedisClient._instance to None on close() to prevent…
Tyjfre-j Jul 4, 2026
739659e
fix(nats): wrap drain()/close() in try/finally so NatsClient singleto…
Tyjfre-j Jul 4, 2026
4c76daf
fix: respect MULTAI_RUN_E2E in stats endpoint tests
Tyjfre-j Jul 4, 2026
c5b7264
fix: ruff and mypy check
Tyjfre-j Jul 4, 2026
510b75a
fix added email worker to run-workers command
Tyjfre-j Jul 4, 2026
ef58ae8
fix: fixed env prefix in notification settings
Tyjfre-j Jul 4, 2026
7caee68
fix: fixed storage cleaner db connect
Tyjfre-j Jul 4, 2026
dee6f8a
fix: fix ruff
Tyjfre-j Jul 4, 2026
ea872d8
fix: added workers to mobile docker file
Tyjfre-j Jul 4, 2026
ee62217
fix: added workers to staging docker file
Tyjfre-j Jul 4, 2026
ab6643c
fix: updated mobile run docs
Tyjfre-j Jul 5, 2026
f1bd274
fix: removed dup opencv dep
Tyjfre-j Jul 5, 2026
3df3a69
feat(uploads): add UploadRequestGroupSummarySchema for lightweight gr…
Tyjfre-j Jul 5, 2026
a356c2c
fix(uploads): remove N+1 query pattern in list_groups
Tyjfre-j Jul 5, 2026
a6b5ef4
feat(uploads): use summary schema for GET /staff/uploads/groups
Tyjfre-j Jul 5, 2026
9204a66
Fix: scope get/delete session queries by user_id
Tyjfre-j Jul 12, 2026
bb1920a
chore(session): regenerate sqlc output for scoped session queries
Tyjfre-j Jul 12, 2026
5861d52
fix(auth): invalidate session cache and db row on logout
Tyjfre-j Jul 12, 2026
1be0809
fix(auth): scope revoke-device ownership check and fix ordering
Tyjfre-j Jul 12, 2026
643174d
refactor(session): remove dead SessionRedis model and UpsertSessionRo…
Tyjfre-j Jul 13, 2026
69a9625
refactor(constant): remove unused UserSessionByUser RedisKey enum
Tyjfre-j Jul 13, 2026
7e603ff
refactor(users): replace UserSessionByUser redis pattern with session…
Tyjfre-j Jul 13, 2026
e97a480
fix(auth): remove dead RedisKey.UserSessionByUser reference in revoke…
Tyjfre-j Jul 13, 2026
3c02ec6
test(auth): update old logout test to assert session-scoped redis key
Tyjfre-j Jul 13, 2026
0257d49
fix(devices): scope get_device_by_id by user_id, add explicit unscope…
Tyjfre-j Jul 13, 2026
395d7ba
fix(device): pass user_id through to now-scoped get_device_by_id
Tyjfre-j Jul 13, 2026
722ff31
fix(users): use get_device_by_id_any for cross-user device collision …
Tyjfre-j Jul 13, 2026
8076846
test: add get_device_by_id_any to device querier fakes and mocks
Tyjfre-j Jul 13, 2026
22e2c5e
Merge branch 'main' into develop
Tyjfre-j Jul 16, 2026
9f60abb
fix: fix ruff error
Tyjfre-j Jul 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion app/core/constant.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

class RedisKey(str, Enum):
UserSession = "user_session"
UserSessionByUser = "user_session:{user_id}"
INVALID_TOKEN_SET_KEY = "notifications:invalid_tokens"
MobileSessionCache = "session:{session_id}"

Expand Down
20 changes: 13 additions & 7 deletions app/router/mobile/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from fastapi import APIRouter, Depends, Request, UploadFile
from fastapi.responses import Response
from app.core.image_validation import build_image_payload
from app.core.exceptions import AppException
from uuid import UUID

from app.container import get_container, Container
Expand Down Expand Up @@ -123,21 +124,26 @@ async def revoke_device(
container: Container = Depends(get_container),
current_user: MobileUserSchema = Depends(get_current_mobile_user),
) -> dict[str, str]:
from app.core.constant import RedisKey

session = await container.session_service.session_querier.get_session_by_device(
device_id=device_id
device = await container.device_service.get_device_by_id(
device_id=device_id, user_id=current_user.user_id
)
if session:
await container.session_service.delete_session_cache(container.redis, session.id)
if device is None or device.user_id != current_user.user_id:
raise AppException.not_found("Device not found")

user_session_key = RedisKey.UserSessionByUser.value.format(user_id=current_user.user_id)
await container.redis.delete(user_session_key)
session = await container.session_service.session_querier.get_session_by_device_for_user(
device_id=device_id, user_id=current_user.user_id
)

await container.device_service.revoke_device(
device_id=device_id,
user_id=current_user.user_id,
)

if session:
await container.session_service.delete_session_cache(container.redis, session.id)


return {"message": "Device revoked successfully"}


Expand Down
4 changes: 2 additions & 2 deletions app/service/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ async def inactivate_device(
user_id: uuid.UUID,
) -> None:
try:
device = await self.device_querier.get_device_by_id(id=device_id)
device = await self.device_querier.get_device_by_id(id=device_id, user_id=user_id)
if device is None or device.user_id != user_id:
raise AppException.not_found("Device not found")
await self.device_querier.deactivate_device(
Expand All @@ -112,7 +112,7 @@ async def get_device_by_id(
user_id: uuid.UUID,
) -> UserDevice:
try :
device = await self.device_querier.get_device_by_id(id=device_id)
device = await self.device_querier.get_device_by_id(id=device_id, user_id=user_id)
if device is None :
raise AppException.not_found("device not found ")
return device
Expand Down
109 changes: 1 addition & 108 deletions app/service/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,9 @@
from db.generated import session as session_queries
import uuid
from db.generated.models import UserSession
from datetime import datetime, timedelta, timezone
from datetime import datetime
from app.infra.redis import RedisClient
from app.core.constant import RedisKey
from db.generated.session import UpsertSessionRow


class SessionRedis(BaseModel):
session_id: uuid.UUID
user_id: uuid.UUID
device_id: uuid.UUID
last_active: datetime
expires_at: datetime


class MobileSessionCache(BaseModel):
Expand Down Expand Up @@ -74,35 +65,6 @@ async def delete_session_cache(
key = RedisKey.MobileSessionCache.value.format(session_id=session_id)
await redis.delete(key)

@staticmethod
async def create_session(user_id: uuid.UUID, device_id: uuid.UUID) -> UpsertSessionRow:
try:
session = await SessionService.session_querier.upsert_session(
user_id=user_id,
device_id=device_id,
expires_at=datetime.now(timezone.utc) + timedelta(days=7),
)
if session is None:
raise AppException.internal_error("session creation failed ")

result = await SessionService.redis.set(
key=RedisKey.UserSessionByUser.format(user_id=user_id),
value=SessionRedis(
session_id=session.id,
user_id=session.user_id,
device_id=session.device_id,
last_active=session.last_active,
expires_at=session.expires_at,
).model_dump_json(),
expire=60 * 60 * 5,
nx=True,
)
if not result:
AppException.forbidden("You already logged in in another device")
return session
except Exception as e:
raise DBExceptionImpl.handle(e)

@staticmethod
async def get_session_by_id(session_id: uuid.UUID) -> UserSession:
try:
Expand All @@ -113,75 +75,6 @@ async def get_session_by_id(session_id: uuid.UUID) -> UserSession:
except Exception as e:
raise DBExceptionImpl.handle(e)

@staticmethod
async def check_session(
session_id: uuid.UUID,
user_id: uuid.UUID,
device_id: uuid.UUID,
) -> bool:
try:
session_in_redis = await SessionService.redis.get(
RedisKey.UserSessionByUser.format(user_id=user_id)
)

if session_in_redis is None:
return False

session_info = SessionRedis.model_validate_json(session_in_redis)

if session_info:
if session_info.device_id != device_id and session_info.session_id != session_id:
raise AppException.forbidden("You already logged in on another device")

await SessionService.redis.set(
key=RedisKey.UserSessionByUser.format(user_id=user_id),
value=SessionRedis(
session_id=session_info.session_id,
user_id=session_info.user_id,
device_id=session_info.device_id,
last_active=session_info.last_active,
expires_at=session_info.expires_at,
).model_dump_json(),
expire=60 * 60 * 5,
nx=False,
)

return True

session = await SessionService.session_querier.get_session_by_id(id=session_id)

if session is None:
raise AppException.forbidden("Session not found")

await SessionService.redis.set(
key=RedisKey.UserSessionByUser.format(user_id=user_id),
value=SessionRedis(
session_id=session.id,
user_id=session.user_id,
device_id=session.device_id,
last_active=session.last_active,
expires_at=session.expires_at,
).model_dump_json(),
expire=60 * 60 * 5,
nx=True,
)

return True

except Exception as e:
raise DBExceptionImpl.handle(e)

@staticmethod
async def delete_session(
session_id: uuid.UUID, user_id: uuid.UUID, device_id: uuid.UUID
) -> None:
try:
await SessionService.session_querier.delete_session_by_device(
user_id=user_id, device_id=device_id
)
except Exception as e:
raise DBExceptionImpl.handle(e)

@staticmethod
async def delete_expired_sessions() -> None:
try:
Expand Down
46 changes: 16 additions & 30 deletions app/service/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
decode_refresh_mobile_token,
Get_expiry_time,
)
from app.core import constant
from app.core.config import settings
from app.infra.redis import RedisClient
from app.infra.minio import Bucket, IMAGES_BUCKET_NAME
Expand Down Expand Up @@ -63,7 +62,7 @@ async def _ensure_device_for_login(
user_id: uuid.UUID,
req: MobileAuthBaseRequest,
) -> UserDevice:
existing_device = await self.device_querier.get_device_by_id(id=req.device_id)
existing_device = await self.device_querier.get_device_by_id_any(id=req.device_id)

if existing_device:
if existing_device.user_id != user_id:
Expand Down Expand Up @@ -275,8 +274,6 @@ async def _create_mobile_session(
) -> MobileAuthResponse:
user_id: uuid.UUID = user.id

session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id)

session_count = await self.session_querier.count_user_sessions(user_id=user_id)
if session_count and session_count >= AuthService.SESSION_LIMIT:
logger.warning(
Expand All @@ -302,10 +299,6 @@ async def _create_mobile_session(
if not session:
raise AppException.internal_error("Failed to create session")

await redis.set(
session_key, str(session.id), expire=AuthService.REDIS_SESSION_TTL
)

access_token = create_acces_mobile_token(str(session.id))
refresh_token = create_refresh_mobile_token(str(session.id))
expiry = Get_expiry_time()
Expand Down Expand Up @@ -373,8 +366,10 @@ async def logout(
user_id: str,
session_id: str,
) -> dict[str, str]:
session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id)
await redis.delete(session_key)
sid = uuid.UUID(session_id)
await SessionService.delete_session_cache(redis, sid)
await self.session_querier.delete_session_by_id(id=sid, user_id=uuid.UUID(user_id))

return {"message": "Logged out successfully"}

async def add_embbed_user(
Expand Down Expand Up @@ -566,18 +561,14 @@ async def delete_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User:
existing = await self.user_querier.get_user_by_id(id=user_id)
if not existing:
raise AppException.not_found("User not found")

sessions = self.session_querier.list_sessions_by_user(user_id=user_id)
async for s in sessions:
await SessionService.delete_session_cache(redis=redis, session_id=s.id)
await self.session_querier.delete_all_user_sessions(user_id=user_id)

await self.user_querier.delete_user(id=user_id)
session_key = constant.RedisKey.UserSessionByUser.value.format(
user_id=user_id
)
raw_session_id = await redis.get(session_key)
if raw_session_id:
try:
session_id = uuid.UUID(raw_session_id)
await SessionService.delete_session_cache(redis=redis, session_id=session_id)
except (ValueError, Exception):
pass
await redis.delete(session_key)

return existing
except Exception as exc:
logger.error("Failed to delete user: %s", exc)
Expand All @@ -589,15 +580,10 @@ async def block_user(self, *, redis: RedisClient, user_id: uuid.UUID) -> User:
if not user:
raise AppException.not_found("User not found")

session_key = constant.RedisKey.UserSessionByUser.value.format(user_id=user_id)
raw_session_id = await redis.get(session_key)
if raw_session_id:
try:
session_id = uuid.UUID(raw_session_id)
await SessionService.delete_session_cache(redis=redis, session_id=session_id)
except (ValueError, Exception):
pass
await redis.delete(session_key)
sessions = self.session_querier.list_sessions_by_user(user_id=user_id)
async for s in sessions:
await SessionService.delete_session_cache(redis, s.id)
await self.session_querier.delete_all_user_sessions(user_id=user_id)

return user
except Exception as exc:
Expand Down
31 changes: 28 additions & 3 deletions db/generated/devices.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ class CreateDeviceParams:

GET_DEVICE_BY_ID = """-- name: get_device_by_id \\:one
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token from user_devices
WHERE id =:p1
WHERE id = :p1
AND user_id = :p2
"""


GET_DEVICE_BY_ID_ANY = """-- name: get_device_by_id_any \\:one
SELECT id, user_id, device_name, device_type, totp_secret, is_2fa_enabled, last_active, created_at, push_token, is_active, is_invalid_token from user_devices
WHERE id = :p1
"""


Expand Down Expand Up @@ -159,8 +166,26 @@ async def deactivate_device(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
async def enable_device2_fa(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
await self._conn.execute(sqlalchemy.text(ENABLE_DEVICE2_FA), {"p1": id, "p2": user_id})

async def get_device_by_id(self, *, id: uuid.UUID) -> Optional[models.UserDevice]:
row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE_BY_ID), {"p1": id})).first()
async def get_device_by_id(self, *, id: uuid.UUID, user_id: uuid.UUID) -> Optional[models.UserDevice]:
row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE_BY_ID), {"p1": id, "p2": user_id})).first()
if row is None:
return None
return models.UserDevice(
id=row[0],
user_id=row[1],
device_name=row[2],
device_type=row[3],
totp_secret=row[4],
is_2fa_enabled=row[5],
last_active=row[6],
created_at=row[7],
push_token=row[8],
is_active=row[9],
is_invalid_token=row[10],
)

async def get_device_by_id_any(self, *, id: uuid.UUID) -> Optional[models.UserDevice]:
row = (await self._conn.execute(sqlalchemy.text(GET_DEVICE_BY_ID_ANY), {"p1": id})).first()
if row is None:
return None
return models.UserDevice(
Expand Down
17 changes: 13 additions & 4 deletions db/generated/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,16 @@
"""


GET_SESSION_BY_DEVICE = """-- name: get_session_by_device \\:one
DELETE_SESSION_BY_ID = """-- name: delete_session_by_id \\:exec
DELETE FROM user_sessions
WHERE id = :p1 AND user_id = :p2
"""


GET_SESSION_BY_DEVICE_FOR_USER = """-- name: get_session_by_device_for_user \\:one
SELECT id, user_id, device_id, created_at, last_active, expires_at
FROM user_sessions
WHERE device_id = :p1
WHERE device_id = :p1 AND user_id = :p2
"""


Expand Down Expand Up @@ -116,8 +122,11 @@ async def delete_expired_sessions(self) -> None:
async def delete_session_by_device(self, *, device_id: uuid.UUID, user_id: uuid.UUID) -> None:
await self._conn.execute(sqlalchemy.text(DELETE_SESSION_BY_DEVICE), {"p1": device_id, "p2": user_id})

async def get_session_by_device(self, *, device_id: uuid.UUID) -> Optional[models.UserSession]:
row = (await self._conn.execute(sqlalchemy.text(GET_SESSION_BY_DEVICE), {"p1": device_id})).first()
async def delete_session_by_id(self, *, id: uuid.UUID, user_id: uuid.UUID) -> None:
await self._conn.execute(sqlalchemy.text(DELETE_SESSION_BY_ID), {"p1": id, "p2": user_id})

async def get_session_by_device_for_user(self, *, device_id: uuid.UUID, user_id: uuid.UUID) -> Optional[models.UserSession]:
row = (await self._conn.execute(sqlalchemy.text(GET_SESSION_BY_DEVICE_FOR_USER), {"p1": device_id, "p2": user_id})).first()
if row is None:
return None
return models.UserSession(
Expand Down
7 changes: 6 additions & 1 deletion db/queries/devices.sql
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,14 @@ WHERE id = $1
AND user_id = $2
AND is_2fa_enabled = FALSE;

-- name: GetDeviceByIdAny :one
SELECT * from user_devices
WHERE id = $1;

-- name: GetDeviceById :one
SELECT * from user_devices
WHERE id =$1;
WHERE id = $1
AND user_id = $2;

-- name: CountUserDevices :one
SELECT COUNT(*)
Expand Down
Loading