fix: bootstrap service account with BYPASSRLS session
This commit is contained in:
@@ -14,6 +14,8 @@ import logging
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.admin_database import _admin_session_factory
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SERVICE_ACCOUNT_EMAIL = "noreply@resolutionflow.com"
|
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:
|
async def ensure_service_account(db: AsyncSession) -> uuid.UUID:
|
||||||
"""Ensure the ResolutionFlow service account exists and return its ID.
|
"""Ensure the ResolutionFlow service account exists and return its ID.
|
||||||
|
|
||||||
Idempotent — safe to call on every startup. Creates the account if it
|
Idempotent — safe to call on every startup. This lookup must bypass RLS
|
||||||
does not exist. The account has no usable password and is_service_account=True
|
because startup runs before any request-scoped tenant context exists and
|
||||||
so it can never log in via normal auth flows.
|
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
|
from app.models.user import User
|
||||||
|
|
||||||
result = await db.execute(
|
async with _admin_session_factory() as admin_db:
|
||||||
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
|
result = await admin_db.execute(
|
||||||
)
|
select(User).where(User.email == SERVICE_ACCOUNT_EMAIL)
|
||||||
user = result.scalar_one_or_none()
|
)
|
||||||
|
user = result.scalar_one_or_none()
|
||||||
|
|
||||||
if user is not None:
|
if user is not None:
|
||||||
if not user.is_service_account:
|
if not user.is_service_account:
|
||||||
user.is_service_account = True
|
user.is_service_account = True
|
||||||
await db.commit()
|
await admin_db.commit()
|
||||||
return user.id
|
return user.id
|
||||||
|
|
||||||
account_id = await _ensure_system_account(db)
|
account_id = await _ensure_system_account(admin_db)
|
||||||
|
|
||||||
new_user = User(
|
new_user = User(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
email=SERVICE_ACCOUNT_EMAIL,
|
email=SERVICE_ACCOUNT_EMAIL,
|
||||||
name=SERVICE_ACCOUNT_NAME,
|
name=SERVICE_ACCOUNT_NAME,
|
||||||
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
|
password_hash="!service-account-no-login", # bcrypt can't produce this prefix
|
||||||
role="engineer",
|
role="engineer",
|
||||||
is_super_admin=False,
|
is_super_admin=False,
|
||||||
is_team_admin=False,
|
is_team_admin=False,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_service_account=True,
|
is_service_account=True,
|
||||||
must_change_password=False,
|
must_change_password=False,
|
||||||
account_id=account_id,
|
account_id=account_id,
|
||||||
account_role="engineer",
|
account_role="engineer",
|
||||||
)
|
)
|
||||||
db.add(new_user)
|
admin_db.add(new_user)
|
||||||
await db.commit()
|
await admin_db.commit()
|
||||||
logger.info(f"[service_account] Created service account (id={new_user.id})")
|
logger.info(f"[service_account] Created service account (id={new_user.id})")
|
||||||
return new_user.id
|
return new_user.id
|
||||||
|
|||||||
89
backend/tests/test_service_account.py
Normal file
89
backend/tests/test_service_account.py
Normal file
@@ -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
|
||||||
Reference in New Issue
Block a user