oliver-sales-ops-platform/backend/scripts/seed_admin.py
DJP b8dadb4f99 Deploy infra + admin user seed + URL path migration
Three things needed for the optical-dev rollout.

1) Path migration /osop/ → /oliver-sales-ops-platform/
   The public URL is https://optical-dev.oliver.solutions/oliver-sales-ops-platform/
   Updated the basename across:
   - Vite base + proxy match
   - React Router basename
   - axios baseURL
   - MSAL redirectUri (preserves SSO when wired)
   - downloadQAPack URL
   - Backend app_path_prefix default
   - docker-compose APP_PATH_PREFIX default
   - Apache Location blocks

2) DEV_AUTH admin user
   Auth middleware now reads DEV_AUTH_EMAIL / DEV_AUTH_NAME / DEV_AUTH_ROLE
   when DEV_AUTH_BYPASS=true (defaults preserve the old dev@localhost /
   editor behaviour). The dev_bypass identity also promotes existing
   AppUser rows to admin if the env says so — so no manual SQL on the
   server when we want a different account exposed.
   New backend/scripts/seed_admin.py is idempotent and runs from
   start.sh after Alembic migrations. It upserts the configured
   DEV_AUTH_EMAIL with role=admin (or whatever DEV_AUTH_ROLE says).
   Smoke-tested locally: /api/users/me now returns
   admin@oliver.agency / role=admin.

3) Deploy assets under deploy/
   - apache-osop.conf — drop-in vhost block (Location /…/api/ → 8003,
     Location /…/ → 3011, ProxyTimeout 300, redirect bare prefix to /).
     Sits alongside the existing /gsb/ V1 block on the same vhost.
   - deploy.sh — idempotent script:
     * sanity (.env present, docker on PATH)
     * port-conflict check (5435 db, 6380 redis, 8003 backend, 3011
       frontend) — if our compose project is already running, skips
       the lsof check because the ports are ours; otherwise warns and
       exits if anything else is listening
     * git pull --ff-only (skip with --no-pull)
     * docker compose build && up -d (skip with --no-build)
     * health-poll backend /api/health for up to 60s
     * frontend probe at the new path
     * prints local + public URLs and the admin email on success

V1 host ports (5432/8002/3010) and V2 host ports (5435/6380/8003/3011)
are non-overlapping by design, so both stacks coexist on the same dev
server. CLAUDE.md naming policy is satisfied — docker-compose.yml has
name: oliver-sales-ops-platform pinned.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 16:08:50 -04:00

74 lines
2.7 KiB
Python

"""Idempotent admin-user seed script.
Runs at container start (after Alembic migrations) to ensure the dev-bypass
admin email exists as an AppUser with role=admin. If the user already
exists with a lower role, this script promotes them.
Reads the same DEV_AUTH_* env vars the auth middleware uses, so they stay
in sync. Pure-sync (psycopg2) — no async stack at boot.
"""
from __future__ import annotations
import logging
import os
import sys
# Make sure /app is on the path when this is invoked from start.sh.
sys.path.insert(0, "/app")
from sqlalchemy import create_engine, text # noqa: E402
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s [%(name)s] %(message)s")
def main() -> None:
bypass = os.environ.get("DEV_AUTH_BYPASS", "").lower() in ("1", "true", "yes")
if not bypass:
logger.info("DEV_AUTH_BYPASS not set — skipping admin seed (real SSO will provision users on login)")
return
email = (os.environ.get("DEV_AUTH_EMAIL") or "admin@oliver.agency").strip().lower()
name = os.environ.get("DEV_AUTH_NAME") or "OSOP Admin"
role = (os.environ.get("DEV_AUTH_ROLE") or "admin").strip().lower()
if role not in {"viewer", "editor", "admin"}:
logger.warning("Unknown DEV_AUTH_ROLE=%s — falling back to admin", role)
role = "admin"
sync_url = os.environ.get("DATABASE_URL_SYNC")
if not sync_url:
logger.error("DATABASE_URL_SYNC not set — cannot seed admin")
return
engine = create_engine(sync_url)
try:
with engine.begin() as conn:
existing = conn.execute(
text("SELECT id, role FROM app_users WHERE email = :email"),
{"email": email},
).fetchone()
if existing is None:
conn.execute(
text(
"INSERT INTO app_users (email, name, role, last_login) "
"VALUES (:email, :name, CAST(:role AS userrole), NOW())"
),
{"email": email, "name": name, "role": role.upper()},
)
logger.info("Seeded AppUser %s (role=%s)", email, role)
elif existing.role.lower() != role and role == "admin":
conn.execute(
text("UPDATE app_users SET role = CAST(:role AS userrole) WHERE id = :id"),
{"role": role.upper(), "id": existing.id},
)
logger.info("Promoted existing AppUser %s to %s", email, role)
else:
logger.info("AppUser %s already exists with role=%s — no change", email, existing.role.lower())
finally:
engine.dispose()
if __name__ == "__main__":
main()