diff --git a/backend/app/core/service_account.py b/backend/app/core/service_account.py index 9d00a1d9..d91c390f 100644 --- a/backend/app/core/service_account.py +++ b/backend/app/core/service_account.py @@ -14,6 +14,8 @@ import logging from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.core.admin_database import _admin_session_factory + logger = logging.getLogger(__name__) SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com" @@ -52,40 +54,45 @@ async def _ensure_system_account(db: AsyncSession) -> uuid.UUID: async def ensure_service_account(db: AsyncSession) -> uuid.UUID: """Ensure the ResolutionFlow service account exists and return its ID. - Idempotent — safe to call on every startup. Creates the account if it - does not exist. The account has no usable password and is_service_account=True - so it can never log in via normal auth flows. + Idempotent — safe to call on every startup. This lookup must bypass RLS + because startup runs before any request-scoped tenant context exists and + the users table is tenant-isolated in Phase 4. The service account is + normally created by Alembic migration 1490781700bc; the runtime create path + remains as a self-healing fallback for environments that predate that seed. """ + _ = db # Retained for call-site compatibility in app lifespan startup. + from app.models.user import User - result = await db.execute( - select(User).where(User.email == SERVICE_ACCOUNT_EMAIL) - ) - user = result.scalar_one_or_none() + async with _admin_session_factory() as admin_db: + result = await admin_db.execute( + select(User).where(User.email == SERVICE_ACCOUNT_EMAIL) + ) + user = result.scalar_one_or_none() - if user is not None: - if not user.is_service_account: - user.is_service_account = True - await db.commit() - return user.id + if user is not None: + if not user.is_service_account: + user.is_service_account = True + await admin_db.commit() + return user.id - account_id = await _ensure_system_account(db) + account_id = await _ensure_system_account(admin_db) - new_user = User( - id=uuid.uuid4(), - email=SERVICE_ACCOUNT_EMAIL, - name=SERVICE_ACCOUNT_NAME, - password_hash="!service-account-no-login", # bcrypt can't produce this prefix - role="engineer", - is_super_admin=False, - is_team_admin=False, - is_active=True, - is_service_account=True, - must_change_password=False, - account_id=account_id, - account_role="engineer", - ) - db.add(new_user) - await db.commit() - logger.info(f"[service_account] Created service account (id={new_user.id})") - return new_user.id + new_user = User( + id=uuid.uuid4(), + email=SERVICE_ACCOUNT_EMAIL, + name=SERVICE_ACCOUNT_NAME, + password_hash="!service-account-no-login", # bcrypt can't produce this prefix + role="engineer", + is_super_admin=False, + is_team_admin=False, + is_active=True, + is_service_account=True, + must_change_password=False, + account_id=account_id, + account_role="engineer", + ) + admin_db.add(new_user) + await admin_db.commit() + logger.info(f"[service_account] Created service account (id={new_user.id})") + return new_user.id diff --git a/backend/tests/test_service_account.py b/backend/tests/test_service_account.py new file mode 100644 index 00000000..9609cc60 --- /dev/null +++ b/backend/tests/test_service_account.py @@ -0,0 +1,89 @@ +import pytest +from sqlalchemy import select + +from app.core import service_account as service_account_module +from app.core.service_account import ( + SERVICE_ACCOUNT_EMAIL, + SYSTEM_ACCOUNT_DISPLAY_CODE, + ensure_service_account, +) +from app.models.account import Account +from app.models.user import User + + +class _SessionFactoryOverride: + def __init__(self, session): + self._session = session + + def __call__(self): + return self + + async def __aenter__(self): + return self._session + + async def __aexit__(self, exc_type, exc, tb): + return False + + +@pytest.mark.asyncio +async def test_ensure_service_account_creates_and_reuses_seeded_user(test_db, monkeypatch): + monkeypatch.setattr( + service_account_module, + "_admin_session_factory", + _SessionFactoryOverride(test_db), + ) + + service_account_id = await ensure_service_account(test_db) + + created_user = ( + await test_db.execute(select(User).where(User.id == service_account_id)) + ).scalar_one() + assert created_user.email == SERVICE_ACCOUNT_EMAIL + assert created_user.is_service_account is True + + system_account = ( + await test_db.execute( + select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE) + ) + ).scalar_one() + assert created_user.account_id == system_account.id + + second_id = await ensure_service_account(test_db) + assert second_id == service_account_id + + +@pytest.mark.asyncio +async def test_ensure_service_account_marks_existing_user_as_service_account(test_db, monkeypatch): + monkeypatch.setattr( + service_account_module, + "_admin_session_factory", + _SessionFactoryOverride(test_db), + ) + + system_account = ( + await test_db.execute( + select(Account).where(Account.display_code == SYSTEM_ACCOUNT_DISPLAY_CODE) + ) + ).scalar_one() + + existing_user = User( + email=SERVICE_ACCOUNT_EMAIL, + name="ResolutionFlow", + password_hash="!service-account-no-login", + role="engineer", + is_super_admin=False, + is_team_admin=False, + is_active=True, + is_service_account=False, + must_change_password=False, + account_id=system_account.id, + account_role="engineer", + ) + test_db.add(existing_user) + await test_db.commit() + + resolved_id = await ensure_service_account(test_db) + await test_db.refresh(existing_user) + + assert resolved_id == existing_user.id + assert existing_user.is_service_account is True