Phase 3 of Migration

This commit is contained in:
2026-04-17 15:39:29 +03:00
parent c7d5206d0c
commit 83361fad77
9 changed files with 481 additions and 198 deletions

View File

@@ -0,0 +1,32 @@
"""phase_3_staff_ui_prefs
Adds ui_prefs JSONB column to the staff table (Phase 3 — staff auth cutover).
Also corrects permissions to be nullable (sysadmin/admin have NULL permissions).
Revision ID: c3d4e5f6a7b8
Revises: b1c2d3e4f5a6
Create Date: 2026-04-17 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB
from alembic import op
revision: str = "c3d4e5f6a7b8"
down_revision: Union[str, None] = "b1c2d3e4f5a6"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"staff",
sa.Column("ui_prefs", JSONB, nullable=False, server_default="{}"),
)
# permissions was NOT NULL DEFAULT '{}' — relax to nullable for sysadmin/admin
op.alter_column("staff", "permissions", nullable=True)
def downgrade() -> None:
op.drop_column("staff", "ui_prefs")
op.alter_column("staff", "permissions", nullable=False)

View File

@@ -1,10 +1,14 @@
from fastapi import Depends from fastapi import Depends
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from auth.utils import decode_access_token from auth.utils import decode_access_token
from auth.models import TokenPayload, Role from auth.models import TokenPayload, Role
from database.postgres import get_pg_session
from staff.orm import Staff
from shared.exceptions import AuthenticationError, AuthorizationError from shared.exceptions import AuthenticationError, AuthorizationError
from shared.firebase import get_db
security = HTTPBearer() security = HTTPBearer()
@@ -37,18 +41,15 @@ def require_roles(*allowed_roles: Role):
return role_checker return role_checker
async def _get_user_permissions(user: TokenPayload) -> dict: async def _get_user_permissions(user: TokenPayload, db: AsyncSession) -> dict | None:
"""Fetch permissions from Firestore for the given user.""" """Fetch permissions from Postgres for the given user."""
if user.role in (Role.sysadmin, Role.admin): if user.role in (Role.sysadmin, Role.admin):
return None # Full access return None # Full access
db = get_db() result = await db.execute(select(Staff).where(Staff.id == user.sub).limit(1))
if not db: staff = result.scalar_one_or_none()
if staff is None:
raise AuthorizationError() raise AuthorizationError()
doc = db.collection("admin_users").document(user.sub).get() return staff.permissions
if not doc.exists:
raise AuthorizationError()
data = doc.to_dict()
return data.get("permissions")
def require_permission(section: str, action: str): def require_permission(section: str, action: str):
@@ -58,17 +59,17 @@ def require_permission(section: str, action: str):
""" """
async def permission_checker( async def permission_checker(
current_user: TokenPayload = Depends(get_current_user), current_user: TokenPayload = Depends(get_current_user),
db: AsyncSession = Depends(get_pg_session),
) -> TokenPayload: ) -> TokenPayload:
# sysadmin and admin have full access
if current_user.role in (Role.sysadmin, Role.admin): if current_user.role in (Role.sysadmin, Role.admin):
return current_user return current_user
permissions = await _get_user_permissions(current_user) permissions = await _get_user_permissions(current_user, db)
if not permissions: if not permissions:
raise AuthorizationError() raise AuthorizationError()
if section == "mqtt": if section == "mqtt":
if not permissions.get("mqtt", False): if not permissions.get("mqtt", {}).get("access", False):
raise AuthorizationError() raise AuthorizationError()
return current_user return current_user
@@ -89,11 +90,7 @@ def require_permission(section: str, action: str):
# Pre-built convenience dependencies # Pre-built convenience dependencies
require_sysadmin = require_roles(Role.sysadmin) require_sysadmin = require_roles(Role.sysadmin)
require_admin_or_above = require_roles(Role.sysadmin, Role.admin) require_admin_or_above = require_roles(Role.sysadmin, Role.admin)
# Staff management: only sysadmin and admin
require_staff_management = require_roles(Role.sysadmin, Role.admin) require_staff_management = require_roles(Role.sysadmin, Role.admin)
# Viewer-level: any authenticated user (actual permission check per-action)
require_any_authenticated = require_roles( require_any_authenticated = require_roles(
Role.sysadmin, Role.admin, Role.editor, Role.user, Role.sysadmin, Role.admin, Role.editor, Role.user,
) )

View File

@@ -1,59 +1,57 @@
from fastapi import APIRouter from fastapi import APIRouter, Depends
from shared.firebase import get_db from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from database.postgres import get_pg_session
from staff.orm import Staff
from auth.models import LoginRequest, TokenResponse from auth.models import LoginRequest, TokenResponse
from auth.utils import verify_password, create_access_token from auth.utils import verify_password, create_access_token
from shared.exceptions import AuthenticationError from shared.exceptions import AuthenticationError
router = APIRouter(prefix="/api/auth", tags=["auth"]) router = APIRouter(prefix="/api/auth", tags=["auth"])
_ROLE_MAP = {
"superadmin": "sysadmin",
"melody_editor": "editor",
"device_manager": "editor",
"user_manager": "editor",
"viewer": "user",
"staff": "user",
}
@router.post("/login", response_model=TokenResponse) @router.post("/login", response_model=TokenResponse)
async def login(body: LoginRequest): async def login(body: LoginRequest, db: AsyncSession = Depends(get_pg_session)):
db = get_db() result = await db.execute(
if not db: select(Staff).where(Staff.email == body.email).limit(1)
raise AuthenticationError("Service unavailable") )
staff = result.scalar_one_or_none()
users_ref = db.collection("admin_users") if staff is None:
query = users_ref.where("email", "==", body.email).limit(1).get()
if not query:
raise AuthenticationError("Invalid email or password") raise AuthenticationError("Invalid email or password")
doc = query[0] if not staff.is_active:
user_data = doc.to_dict()
if not user_data.get("is_active", True):
raise AuthenticationError("Account is disabled") raise AuthenticationError("Account is disabled")
if not verify_password(body.password, user_data["hashed_password"]): if not verify_password(body.password, staff.hashed_password):
raise AuthenticationError("Invalid email or password") raise AuthenticationError("Invalid email or password")
role = user_data["role"] role = _ROLE_MAP.get(staff.role, staff.role)
# Map legacy roles to new roles
role_mapping = {
"superadmin": "sysadmin",
"melody_editor": "editor",
"device_manager": "editor",
"user_manager": "editor",
"viewer": "user",
}
role = role_mapping.get(role, role)
token = create_access_token({ token = create_access_token({
"sub": doc.id, "sub": staff.id,
"email": user_data["email"], "email": staff.email,
"role": role, "role": role,
"name": user_data["name"], "name": staff.name,
}) })
# Get permissions for editor/user roles
permissions = None permissions = None
if role in ("editor", "user"): if role in ("editor", "user"):
permissions = user_data.get("permissions") permissions = staff.permissions
return TokenResponse( return TokenResponse(
access_token=token, access_token=token,
role=role, role=role,
name=user_data["name"], name=staff.name,
permissions=permissions, permissions=permissions,
) )

View File

@@ -0,0 +1,143 @@
"""
Phase 3 — Step 3.1: admin_users (Firestore → Postgres staff table)
Reads every document in the 'admin_users' Firestore collection and inserts
a matching row into the Postgres 'staff' table.
Key transformations:
- Legacy role names mapped to canonical roles (superadmin→sysadmin, etc.)
- permissions=None stored as JSONB null (sysadmin/admin have no permission map)
- ui_prefs column NOT migrated (not part of the Postgres schema — dropped)
- Firestore doc ID preserved as staff.id and staff.firestore_id
- created_at/updated_at default to now() if missing from Firestore doc
Run on VPS:
docker compose exec backend python -m migration.migrate_staff
"""
import asyncio
import sys
from datetime import datetime, timezone
from sqlalchemy.dialects.postgresql import insert as pg_insert
from staff.orm import Staff
from shared.firebase import init_firebase, get_db as get_firestore
from migration.utils import AsyncPgSession, parse_dt, log_run, pg_count
SCRIPT = "migrate_staff"
COLLECTION = "admin_users"
_ROLE_MAP = {
"superadmin": "sysadmin",
"melody_editor": "editor",
"device_manager": "editor",
"user_manager": "editor",
"viewer": "user",
# canonical roles pass through unchanged
"sysadmin": "sysadmin",
"admin": "admin",
"editor": "editor",
"user": "user",
"staff": "user",
}
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
def _coerce_dt(val) -> datetime | None:
if val is None:
return None
if isinstance(val, datetime):
return val.replace(tzinfo=timezone.utc) if val.tzinfo is None else val
return parse_dt(str(val))
async def run() -> None:
init_firebase()
fs = get_firestore()
if fs is None:
print("ERROR: Firebase not initialised.", file=sys.stderr)
sys.exit(1)
docs = list(fs.collection(COLLECTION).stream())
source_count = len(docs)
print(f"Source (Firestore): {source_count} admin_users documents")
if source_count == 0:
print("Nothing to migrate.")
await log_run(SCRIPT, 0, 0, notes="source empty")
return
records = []
skipped = 0
for doc in docs:
d = doc.to_dict()
hashed_password = d.get("hashed_password") or ""
if not hashed_password:
print(f" WARNING: {doc.id} ({d.get('email')}) has no hashed_password — skipping",
file=sys.stderr)
skipped += 1
continue
email = d.get("email") or ""
if not email:
print(f" WARNING: {doc.id} has no email — skipping", file=sys.stderr)
skipped += 1
continue
raw_role = d.get("role") or "user"
role = _ROLE_MAP.get(raw_role, "user")
# sysadmin/admin have no permission map
permissions = d.get("permissions")
if role in ("sysadmin", "admin"):
permissions = None
now = _now_utc()
records.append({
"id": doc.id,
"firestore_id": doc.id,
"email": email,
"name": d.get("name") or "",
"role": role,
"permissions": permissions,
"hashed_password": hashed_password,
"is_active": bool(d.get("is_active", True)),
"created_at": _coerce_dt(d.get("created_at")) or now,
"updated_at": _coerce_dt(d.get("updated_at")) or now,
})
actual_source = source_count - skipped
print(f" {skipped} skipped (missing email or password), {actual_source} to insert")
if not records:
print("Nothing to insert after filtering.")
await log_run(SCRIPT, source_count, 0, success=False,
notes="all docs skipped — missing required fields")
sys.exit(1)
async with AsyncPgSession() as session:
async with session.begin():
stmt = pg_insert(Staff).values(records)
stmt = stmt.on_conflict_do_nothing(index_elements=["id"])
await session.execute(stmt)
dest_count = await pg_count(session, "staff")
if dest_count < actual_source:
msg = f"Count mismatch: expected>={actual_source} postgres={dest_count}"
print(f"ERROR: {msg}", file=sys.stderr)
await log_run(SCRIPT, source_count, dest_count, success=False, notes=msg)
sys.exit(1)
print(f"Postgres: {dest_count} rows ✓")
note = f"{skipped} skipped (missing fields)" if skipped else None
await log_run(SCRIPT, source_count, dest_count, notes=note)
if __name__ == "__main__":
asyncio.run(run())

View File

@@ -0,0 +1,66 @@
"""
Seed script to create the first sysadmin user directly in Postgres.
Use this after Phase 3 cutover — do not use seed_admin.py (Firestore) anymore.
Usage:
python seed_admin_postgres.py
python seed_admin_postgres.py --email admin@bellsystems.com --password secret --name "Admin"
"""
import argparse
import asyncio
import sys
import uuid
from datetime import datetime, timezone
from getpass import getpass
from sqlalchemy import select
from database.postgres import AsyncSessionLocal
from staff.orm import Staff
from auth.utils import hash_password
async def seed_superadmin(email: str, password: str, name: str) -> None:
async with AsyncSessionLocal() as db:
existing = await db.execute(select(Staff).where(Staff.email == email).limit(1))
if existing.scalar_one_or_none() is not None:
print(f"User with email '{email}' already exists. Aborting.")
sys.exit(1)
now = datetime.now(timezone.utc)
uid = str(uuid.uuid4())
staff = Staff(
id=uid,
firestore_id=None,
email=email,
name=name,
role="sysadmin",
hashed_password=hash_password(password),
is_active=True,
permissions=None,
ui_prefs={},
created_at=now,
updated_at=now,
)
db.add(staff)
await db.commit()
print("SysAdmin created successfully!")
print(f" Email: {email}")
print(f" Name: {name}")
print(f" Role: sysadmin")
print(f" ID: {uid}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Seed a sysadmin user in Postgres")
parser.add_argument("--email", default=None)
parser.add_argument("--password", default=None)
parser.add_argument("--name", default=None)
args = parser.parse_args()
email = args.email or input("Email: ")
name = args.name or input("Name: ")
password = args.password or getpass("Password: ")
asyncio.run(seed_superadmin(email, password, name))

View File

@@ -16,8 +16,9 @@ class Staff(Base):
email = Column(String(256), unique=True, nullable=False) email = Column(String(256), unique=True, nullable=False)
name = Column(String(255), nullable=False) name = Column(String(255), nullable=False)
role = Column(String(64), nullable=False, default="staff") role = Column(String(64), nullable=False, default="staff")
permissions = Column(JSONB, nullable=False, default=dict) permissions = Column(JSONB, nullable=True)
hashed_password = Column(String(256), nullable=False) hashed_password = Column(String(256), nullable=False)
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
ui_prefs = Column(JSONB, nullable=False, default=dict)
created_at = Column(DateTime(timezone=True), nullable=False, default=_now) created_at = Column(DateTime(timezone=True), nullable=False, default=_now)
updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now) updated_at = Column(DateTime(timezone=True), nullable=False, default=_now, onupdate=_now)

View File

@@ -1,5 +1,7 @@
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from typing import Any from sqlalchemy.ext.asyncio import AsyncSession
from database.postgres import get_pg_session
from auth.dependencies import get_current_user, require_staff_management from auth.dependencies import get_current_user, require_staff_management
from auth.models import TokenPayload from auth.models import TokenPayload
from staff import service from staff import service
@@ -13,14 +15,19 @@ router = APIRouter(prefix="/api/staff", tags=["staff"])
@router.get("/me", response_model=StaffResponse) @router.get("/me", response_model=StaffResponse)
async def get_current_staff(current_user: TokenPayload = Depends(get_current_user)): async def get_current_staff(
return await service.get_staff_me(current_user.sub) current_user: TokenPayload = Depends(get_current_user),
db: AsyncSession = Depends(get_pg_session),
):
return await service.get_staff_me(db, current_user.sub)
@router.get("/me/preferences", response_model=dict) @router.get("/me/preferences", response_model=dict)
async def get_preferences(current_user: TokenPayload = Depends(get_current_user)): async def get_preferences(
"""Return all UI preferences for the current staff member.""" current_user: TokenPayload = Depends(get_current_user),
return await service.get_preferences(current_user.sub) db: AsyncSession = Depends(get_pg_session),
):
return await service.get_preferences(db, current_user.sub)
@router.patch("/me/preferences/{page_key}", response_model=dict) @router.patch("/me/preferences/{page_key}", response_model=dict)
@@ -28,9 +35,9 @@ async def update_preferences(
page_key: str, page_key: str,
body: PreferencesUpdate, body: PreferencesUpdate,
current_user: TokenPayload = Depends(get_current_user), current_user: TokenPayload = Depends(get_current_user),
db: AsyncSession = Depends(get_pg_session),
): ):
"""Merge preference keys for a specific page into the staff member's stored prefs.""" return await service.update_preferences(db, current_user.sub, page_key, body.prefs)
return await service.update_preferences(current_user.sub, page_key, body.prefs)
@router.get("", response_model=StaffListResponse) @router.get("", response_model=StaffListResponse)
@@ -38,24 +45,28 @@ async def list_staff(
search: str = Query(None), search: str = Query(None),
role: str = Query(None), role: str = Query(None),
current_user: TokenPayload = Depends(require_staff_management), current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
): ):
return await service.list_staff(search=search, role_filter=role) return await service.list_staff(db, search=search, role_filter=role)
@router.get("/{staff_id}", response_model=StaffResponse) @router.get("/{staff_id}", response_model=StaffResponse)
async def get_staff( async def get_staff(
staff_id: str, staff_id: str,
current_user: TokenPayload = Depends(require_staff_management), current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
): ):
return await service.get_staff(staff_id) return await service.get_staff(db, staff_id)
@router.post("", response_model=StaffResponse) @router.post("", response_model=StaffResponse)
async def create_staff( async def create_staff(
body: StaffCreate, body: StaffCreate,
current_user: TokenPayload = Depends(require_staff_management), current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
): ):
return await service.create_staff( return await service.create_staff(
db,
data=body.model_dump(), data=body.model_dump(),
current_user_role=current_user.role, current_user_role=current_user.role,
) )
@@ -66,8 +77,10 @@ async def update_staff(
staff_id: str, staff_id: str,
body: StaffUpdate, body: StaffUpdate,
current_user: TokenPayload = Depends(require_staff_management), current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
): ):
return await service.update_staff( return await service.update_staff(
db,
staff_id=staff_id, staff_id=staff_id,
data=body.model_dump(exclude_unset=True), data=body.model_dump(exclude_unset=True),
current_user_role=current_user.role, current_user_role=current_user.role,
@@ -80,8 +93,10 @@ async def update_staff_password(
staff_id: str, staff_id: str,
body: StaffPasswordUpdate, body: StaffPasswordUpdate,
current_user: TokenPayload = Depends(require_staff_management), current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
): ):
return await service.update_staff_password( return await service.update_staff_password(
db,
staff_id=staff_id, staff_id=staff_id,
new_password=body.new_password, new_password=body.new_password,
current_user_role=current_user.role, current_user_role=current_user.role,
@@ -92,8 +107,10 @@ async def update_staff_password(
async def delete_staff( async def delete_staff(
staff_id: str, staff_id: str,
current_user: TokenPayload = Depends(require_staff_management), current_user: TokenPayload = Depends(require_staff_management),
db: AsyncSession = Depends(get_pg_session),
): ):
return await service.delete_staff( return await service.delete_staff(
db,
staff_id=staff_id, staff_id=staff_id,
current_user_role=current_user.role, current_user_role=current_user.role,
current_user_id=current_user.sub, current_user_id=current_user.sub,

View File

@@ -1,4 +1,9 @@
from shared.firebase import get_db from datetime import datetime, timezone
from sqlalchemy import select, func, or_
from sqlalchemy.ext.asyncio import AsyncSession
from staff.orm import Staff
from auth.utils import hash_password from auth.utils import hash_password
from auth.models import default_permissions_for_role from auth.models import default_permissions_for_role
from shared.exceptions import NotFoundError, AuthorizationError from shared.exceptions import NotFoundError, AuthorizationError
@@ -8,198 +13,190 @@ import uuid
VALID_ROLES = ("sysadmin", "admin", "editor", "user") VALID_ROLES = ("sysadmin", "admin", "editor", "user")
def _staff_doc_to_response(doc_id: str, data: dict) -> dict: def _now() -> datetime:
return datetime.now(timezone.utc)
def _to_response(staff: Staff) -> dict:
return { return {
"id": doc_id, "id": staff.id,
"email": data.get("email", ""), "email": staff.email,
"name": data.get("name", ""), "name": staff.name,
"role": data.get("role", ""), "role": staff.role,
"is_active": data.get("is_active", True), "is_active": staff.is_active,
"permissions": data.get("permissions"), "permissions": staff.permissions,
} }
async def list_staff(search: str = None, role_filter: str = None) -> dict: async def list_staff(db: AsyncSession, search: str = None, role_filter: str = None) -> dict:
db = get_db() stmt = select(Staff)
ref = db.collection("admin_users") if role_filter:
docs = ref.get() stmt = stmt.where(Staff.role == role_filter)
if search:
staff = [] s = f"%{search.lower()}%"
for doc in docs: stmt = stmt.where(
data = doc.to_dict() or_(
if search: func.lower(Staff.name).like(s),
s = search.lower() func.lower(Staff.email).like(s),
if s not in (data.get("name", "").lower()) and s not in (data.get("email", "").lower()): )
continue )
if role_filter: stmt = stmt.order_by(Staff.name)
if data.get("role") != role_filter: result = await db.execute(stmt)
continue rows = result.scalars().all()
staff.append(_staff_doc_to_response(doc.id, data)) return {"staff": [_to_response(r) for r in rows], "total": len(rows)}
return {"staff": staff, "total": len(staff)}
async def get_staff(staff_id: str) -> dict: async def get_staff(db: AsyncSession, staff_id: str) -> dict:
db = get_db() result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
doc = db.collection("admin_users").document(staff_id).get() staff = result.scalar_one_or_none()
if not doc.exists: if staff is None:
raise NotFoundError("Staff member not found") raise NotFoundError("Staff member not found")
return _staff_doc_to_response(doc.id, doc.to_dict()) return _to_response(staff)
async def get_staff_me(user_sub: str) -> dict: async def get_staff_me(db: AsyncSession, user_sub: str) -> dict:
db = get_db() return await get_staff(db, user_sub)
doc = db.collection("admin_users").document(user_sub).get()
if not doc.exists:
raise NotFoundError("Staff member not found")
return _staff_doc_to_response(doc.id, doc.to_dict())
async def create_staff(data: dict, current_user_role: str) -> dict: async def create_staff(db: AsyncSession, data: dict, current_user_role: str) -> dict:
role = data.get("role", "user") role = data.get("role", "user")
if role not in VALID_ROLES: if role not in VALID_ROLES:
raise AuthorizationError(f"Invalid role: {role}") raise AuthorizationError(f"Invalid role: {role}")
# Admin cannot create sysadmin
if current_user_role == "admin" and role == "sysadmin": if current_user_role == "admin" and role == "sysadmin":
raise AuthorizationError("Admin cannot create sysadmin accounts") raise AuthorizationError("Admin cannot create sysadmin accounts")
db = get_db() existing = await db.execute(
select(Staff).where(Staff.email == data["email"]).limit(1)
# Check for duplicate email )
existing = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get() if existing.scalar_one_or_none() is not None:
if existing:
raise AuthorizationError("A staff member with this email already exists") raise AuthorizationError("A staff member with this email already exists")
uid = str(uuid.uuid4())
hashed = hash_password(data["password"])
# Set default permissions for editor/user if not provided
permissions = data.get("permissions") permissions = data.get("permissions")
if permissions is None and role in ("editor", "user"): if permissions is None and role in ("editor", "user"):
permissions = default_permissions_for_role(role) permissions = default_permissions_for_role(role)
doc_data = { uid = str(uuid.uuid4())
"uid": uid, now = _now()
"email": data["email"], staff = Staff(
"hashed_password": hashed, id=uid,
"name": data["name"], firestore_id=None,
"role": role, email=data["email"],
"is_active": True, name=data["name"],
"permissions": permissions, role=role,
} hashed_password=hash_password(data["password"]),
is_active=True,
doc_ref = db.collection("admin_users").document(uid) permissions=permissions,
doc_ref.set(doc_data) created_at=now,
updated_at=now,
return _staff_doc_to_response(uid, doc_data) )
db.add(staff)
await db.commit()
await db.refresh(staff)
return _to_response(staff)
async def update_staff(staff_id: str, data: dict, current_user_role: str, current_user_id: str) -> dict: async def update_staff(
db = get_db() db: AsyncSession,
doc_ref = db.collection("admin_users").document(staff_id) staff_id: str,
doc = doc_ref.get() data: dict,
if not doc.exists: current_user_role: str,
current_user_id: str,
) -> dict:
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found") raise NotFoundError("Staff member not found")
existing = doc.to_dict() if current_user_role == "admin" and staff.role == "sysadmin":
# Admin cannot edit sysadmin accounts
if current_user_role == "admin" and existing.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot modify sysadmin accounts") raise AuthorizationError("Admin cannot modify sysadmin accounts")
# Admin cannot promote to sysadmin
if current_user_role == "admin" and data.get("role") == "sysadmin": if current_user_role == "admin" and data.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot promote to sysadmin") raise AuthorizationError("Admin cannot promote to sysadmin")
update_data = {}
if data.get("email") is not None: if data.get("email") is not None:
# Check for duplicate email dup = await db.execute(
others = db.collection("admin_users").where("email", "==", data["email"]).limit(1).get() select(Staff).where(Staff.email == data["email"], Staff.id != staff_id).limit(1)
for other in others: )
if other.id != staff_id: if dup.scalar_one_or_none() is not None:
raise AuthorizationError("A staff member with this email already exists") raise AuthorizationError("A staff member with this email already exists")
update_data["email"] = data["email"] staff.email = data["email"]
if data.get("name") is not None: if data.get("name") is not None:
update_data["name"] = data["name"] staff.name = data["name"]
if data.get("role") is not None: if data.get("role") is not None:
if data["role"] not in VALID_ROLES: if data["role"] not in VALID_ROLES:
raise AuthorizationError(f"Invalid role: {data['role']}") raise AuthorizationError(f"Invalid role: {data['role']}")
update_data["role"] = data["role"] staff.role = data["role"]
if data.get("is_active") is not None: if data.get("is_active") is not None:
update_data["is_active"] = data["is_active"] staff.is_active = data["is_active"]
if "permissions" in data: if "permissions" in data:
update_data["permissions"] = data["permissions"] staff.permissions = data["permissions"]
if update_data: staff.updated_at = _now()
doc_ref.update(update_data) await db.commit()
await db.refresh(staff)
updated = {**existing, **update_data} return _to_response(staff)
return _staff_doc_to_response(staff_id, updated)
async def update_staff_password(staff_id: str, new_password: str, current_user_role: str) -> dict: async def update_staff_password(
db = get_db() db: AsyncSession,
doc_ref = db.collection("admin_users").document(staff_id) staff_id: str,
doc = doc_ref.get() new_password: str,
if not doc.exists: current_user_role: str,
) -> dict:
result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found") raise NotFoundError("Staff member not found")
if current_user_role == "admin" and staff.role == "sysadmin":
existing = doc.to_dict()
# Admin cannot change sysadmin password
if current_user_role == "admin" and existing.get("role") == "sysadmin":
raise AuthorizationError("Admin cannot modify sysadmin accounts") raise AuthorizationError("Admin cannot modify sysadmin accounts")
hashed = hash_password(new_password) staff.hashed_password = hash_password(new_password)
doc_ref.update({"hashed_password": hashed}) staff.updated_at = _now()
await db.commit()
return {"message": "Password updated successfully"} return {"message": "Password updated successfully"}
async def get_preferences(staff_id: str) -> dict: async def get_preferences(db: AsyncSession, staff_id: str) -> dict:
"""Return the ui_prefs map for a staff member, defaulting to {} if not set.""" """Return ui_prefs JSONB for a staff member."""
db = get_db() result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
doc = db.collection("admin_users").document(staff_id).get() staff = result.scalar_one_or_none()
if not doc.exists: if staff is None:
raise NotFoundError("Staff member not found") raise NotFoundError("Staff member not found")
return doc.to_dict().get("ui_prefs", {}) return staff.ui_prefs or {}
async def update_preferences(staff_id: str, page_key: str, prefs: dict) -> dict: async def update_preferences(db: AsyncSession, staff_id: str, page_key: str, prefs: dict) -> dict:
"""Merge a page-level preferences dict into ui_prefs.<page_key> on the staff document. """Merge page-level preferences into the staff member's ui_prefs column."""
Only the supplied keys are overwritten — other keys in the same page block survive. result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
""" staff = result.scalar_one_or_none()
db = get_db() if staff is None:
doc_ref = db.collection("admin_users").document(staff_id)
doc = doc_ref.get()
if not doc.exists:
raise NotFoundError("Staff member not found") raise NotFoundError("Staff member not found")
existing_prefs = doc.to_dict().get("ui_prefs", {}) current = dict(staff.ui_prefs or {})
page_prefs = {**existing_prefs.get(page_key, {}), **prefs} current[page_key] = {**current.get(page_key, {}), **prefs}
existing_prefs[page_key] = page_prefs staff.ui_prefs = current
staff.updated_at = _now()
doc_ref.update({"ui_prefs": existing_prefs}) await db.commit()
return existing_prefs return current
async def delete_staff(staff_id: str, current_user_role: str, current_user_id: str) -> dict: async def delete_staff(
db = get_db() db: AsyncSession,
doc_ref = db.collection("admin_users").document(staff_id) staff_id: str,
doc = doc_ref.get() current_user_role: str,
if not doc.exists: current_user_id: str,
raise NotFoundError("Staff member not found") ) -> dict:
existing = doc.to_dict()
# Cannot delete self
if staff_id == current_user_id: if staff_id == current_user_id:
raise AuthorizationError("Cannot delete your own account") raise AuthorizationError("Cannot delete your own account")
# Admin cannot delete sysadmin result = await db.execute(select(Staff).where(Staff.id == staff_id).limit(1))
if current_user_role == "admin" and existing.get("role") == "sysadmin": staff = result.scalar_one_or_none()
if staff is None:
raise NotFoundError("Staff member not found")
if current_user_role == "admin" and staff.role == "sysadmin":
raise AuthorizationError("Admin cannot delete sysadmin accounts") raise AuthorizationError("Admin cannot delete sysadmin accounts")
doc_ref.delete() await db.delete(staff)
await db.commit()
return {"message": "Staff member deleted"} return {"message": "Staff member deleted"}

View File

@@ -467,8 +467,8 @@ backend/
|-------|-------------|--------| |-------|-------------|--------|
| 0 | Schema foundation (all tables in Postgres) | **COMPLETE** — applied on VPS 2026-04-17 | | 0 | Schema foundation (all tables in Postgres) | **COMPLETE** — applied on VPS 2026-04-17 |
| 1 | SQLite → Postgres (data migration) | **COMPLETE** — all 12 scripts ran successfully on VPS 2026-04-17 | | 1 | SQLite → Postgres (data migration) | **COMPLETE** — all 12 scripts ran successfully on VPS 2026-04-17 |
| 2 | Firestore → Postgres (data migration) | **IN PROGRESS** — scripts written, not yet run | | 2 | Firestore → Postgres (data migration) | **COMPLETE** all 5 scripts ran successfully on VPS 2026-04-17 |
| 3 | Staff auth cutover | NOT STARTED | | 3 | Staff auth cutover | **COMPLETE** — Postgres auth live 2026-04-17 |
| 4 | Audit log system | NOT STARTED | | 4 | Audit log system | NOT STARTED |
| 5 | MQTT live data cutover | NOT STARTED | | 5 | MQTT live data cutover | NOT STARTED |
@@ -546,3 +546,35 @@ docker compose exec backend python -m migration.migrate_crm_customers
# 2.5 (depends on 2.4) # 2.5 (depends on 2.4)
docker compose exec backend python -m migration.migrate_crm_orders docker compose exec backend python -m migration.migrate_crm_orders
``` ```
---
## Phase 3 — Run Order & Commands
Apply the new Alembic revision first (adds `ui_prefs` column + makes `permissions` nullable):
```bash
# Apply schema change
docker compose exec backend alembic upgrade head
# 3.1 — migrate Firestore admin_users → Postgres staff table
docker compose exec backend python -m migration.migrate_staff
# Verify
docker compose exec postgres psql -U bellsystems_user -d bellsystems_db \
-c "SELECT id, email, role, is_active FROM staff ORDER BY role, name;"
```
After verifying the staff table is populated correctly:
```bash
# Restart the backend so it picks up the new auth/staff code
docker compose restart backend
```
Then test: log in as each role in the Console UI and verify permissions work.
After 24h stable operation, Firestore reads from auth are fully removed (already done in code).
**Rollback:** revert `auth/router.py`, `auth/dependencies.py`, `staff/service.py`, `staff/router.py`
to the Firestore versions — the JWT payload is unchanged so tokens remain valid during rollback.