Files
evo-sync/docs/PLAN_evotor_user_lifecycle.md

15 KiB

Plan: Evotor User Lifecycle + RBAC + Admin Panel

Context

Evotor (Russian POS ecosystem) sends webhook calls to register and verify users who buy the EvoSync app on their marketplace. Currently the web app has no webhook receivers for user lifecycle events, no role-based access control, and no admin panel. We need to:

  1. Receive Evotor user webhooks (/user/create, /user/verify, /user/token)
  2. Create users in pending status, link them to a local account, send an invite to set password
  3. Abstract email/SMS providers (no concrete provider chosen yet — console output for now)
  4. Add role-based access control (system / admin / user) with a full roles+permissions table structure
  5. Build an admin panel for user management
  6. Extend the user profile card with role/status/Evotor metadata

Target: Python/FastAPI on current HEAD (15a362c). The v3 scaffold is minimal — main.py is a bare FastAPI app, web/models/ is empty, web/tasks/celery_app.py is a stub.

Reference: The Node.js commit 854c912 has all existing features (auth, profile, catalog, sync) — use it as the schema and UI reference for porting.


Architecture Decisions

  • Session: Starlette SessionMiddleware (already a FastAPI dep) — request.session["user_id"] cookie, mirroring Node.js pattern. No extra package.
  • RBAC: role enum directly on users table (fast path in middleware) plus roles / permissions / user_roles / role_permissions tables for fine-grained, admin-configurable permissions.
  • Evotor app token: Store a random UUID token in evotor_connections.api_token (new column). No itsdangerous dependency needed. Return this token in webhook responses.
  • password_hash nullable: Users arriving via /user/create have no password yet; they set it via invite link. Migration must ALTER COLUMN to allow NULL.
  • Notifications: Celery tasks on a new notifications queue → abstract EmailProvider/SMSProviderConsoleEmailProvider/ConsoleSMSProvider by default.
  • Migrations: Three incremental migrations. 0002 creates the full schema (conditionally, since live DB may have Node.js tables). 0003 creates RBAC tables + seeds default roles/permissions. 0004 is optional seed for a system user.

File Structure

web/
├── main.py                          — add SessionMiddleware, Jinja2, static, router includes
├── config.py                        — add INVITE_EXPIRE_HOURS, EMAIL_PROVIDER, SMS_PROVIDER
├── database.py                      — add get_db() dependency (sync Session)
├── templates_env.py                 — NEW: Jinja2Templates singleton with datefmt/price filters
│
├── models/
│   ├── __init__.py                  — import all models (for Alembic autogenerate)
│   ├── user.py                      — NEW: User, UserRoleEnum, UserStatusEnum
│   ├── rbac.py                      — NEW: Role, Permission, role_permissions, UserRole
│   └── connections.py               — NEW: EvotorConnection, VkConnection, SyncConfig, etc.
│
├── auth/
│   ├── __init__.py
│   ├── session.py                   — NEW: get_current_user() helper, session utils
│   ├── password.py                  — NEW: hash_password(), verify_password() via passlib
│   └── rbac.py                      — NEW: require_role(), require_permission() FastAPI deps
│
├── notifications/
│   ├── __init__.py
│   ├── base.py                      — NEW: abstract EmailProvider, SMSProvider
│   ├── console.py                   — NEW: ConsoleEmailProvider, ConsoleSMSProvider
│   ├── registry.py                  — NEW: get_email_provider(), get_sms_provider() factories
│   └── tasks.py                     — NEW: send_email_task, send_sms_task Celery tasks
│
├── routes/
│   ├── __init__.py
│   ├── auth.py                      — NEW: register, login, logout, confirm-email
│   ├── reset.py                     — NEW: forgot-password, reset-password
│   ├── invite.py                    — NEW: GET/POST /invite (Evotor invite flow)
│   ├── profile.py                   — NEW: /profile, /profile/edit, change-password, delete
│   ├── connections.py               — NEW: /connections (port from Node.js)
│   ├── evotor.py                    — NEW: /evotor UI + /evotor/token (port from Node.js)
│   ├── evotor_webhooks.py           — NEW: POST /user/create, /user/verify, /user/token
│   ├── vk.py                        — NEW: /vk + /vk/callback (port from Node.js)
│   ├── catalog.py                   — NEW: /catalog (port from Node.js)
│   ├── sync.py                      — NEW: /sync (port from Node.js)
│   └── admin.py                     — NEW: /admin/* panel
│
├── tasks/
│   ├── celery_app.py                — add notifications queue + route
│   ├── sync.py                      — NEW: sync Celery tasks
│   └── health.py                    — NEW: health-check tasks
│
├── templates/
│   ├── base.html                    — port base.njk; nav shows Admin link if role=admin/system
│   ├── login.html, register.html, confirm_email.html, email_confirmed.html
│   ├── forgot_password.html, reset_password.html
│   ├── invite.html                  — NEW: set-password form for invite flow
│   ├── message.html, profile_view.html, profile_edit.html
│   ├── profile_change_password.html, profile_delete.html
│   ├── connections.html, connections_add.html
│   ├── evotor.html, vk.html, vk_callback.html
│   ├── catalog_stores.html, catalog_groups.html, catalog_products.html
│   ├── sync.html
│   └── admin/
│       ├── users.html               — paginated user list with filters
│       ├── user_detail.html         — user card + Evotor meta JSON + action buttons
│       └── roles.html               — roles and permission assignment
│
├── static/
│   └── style.css                    — port from 854c912:web/static/style.css
│
└── migrations/versions/
    ├── 0001_initial.py              — exists (empty placeholder)
    ├── 0002_users_and_connections.py — NEW: full schema + new user fields
    ├── 0003_rbac_tables.py          — NEW: RBAC tables + seed roles/permissions
    └── 0004_seed_system_user.py     — NEW (optional): seed system user from env vars

DB Schema Additions

web/models/user.py

class UserRoleEnum(str, enum.Enum):
    system = "system"
    admin = "admin"
    user = "user"

class UserStatusEnum(str, enum.Enum):
    pending = "pending"
    active = "active"
    suspended = "suspended"

class User(Base):
    __tablename__ = "users"
    # existing fields (port from 854c912 schema.ts)
    id, first_name, last_name, email, phone
    password_hash = Column(String(255), nullable=True)   # nullable: invite flow
    is_email_confirmed, email_confirm_token
    password_reset_token, password_reset_expires
    created_at, updated_at
    # new fields
    role     = Column(Enum(UserRoleEnum), default=UserRoleEnum.user, index=True)
    status   = Column(Enum(UserStatusEnum), default=UserStatusEnum.pending, index=True)
    evotor_user_id  = Column(String(255), unique=True, nullable=True)
    evotor_meta     = Column(JSON, nullable=True)
    invite_token    = Column(String(255), nullable=True)
    invite_expires  = Column(DateTime, nullable=True)
    phone_otp       = Column(String(10), nullable=True)
    phone_otp_expires = Column(DateTime, nullable=True)

web/models/connections.py

Port all tables from 854c912:web/src/db/schema.ts verbatim: evotor_connections (add api_token varchar(255) column for our app token), vk_connections, sync_configs, sync_filters, cached_stores, cached_groups, cached_products.

web/models/rbac.py

class Role(Base):           # name: system/admin/user
class Permission(Base):     # name: e.g. "admin.users.edit"
role_permissions = Table(...)   # join table Role ↔ Permission
class UserRole(Base):       # join table User ↔ Role (fine-grained assignment)

Route List

Evotor Webhooks (web/routes/evotor_webhooks.py)

Method Path Auth
POST /user/create Bearer EVOTOR_WEBHOOK_SECRET
POST /user/verify Bearer EVOTOR_WEBHOOK_SECRET
POST /user/token Bearer EVOTOR_WEBHOOK_SECRET

/user/create flow:

  1. Verify Bearer token (401 if wrong, skip check if secret is unset — dev mode)
  2. Parse {userId, customField} — attempt JSON parse of customField; extract email, phone, first_name, last_name
  3. Try to find existing user by email → phone → evotor_user_id
  4. If found: set evotor_user_id, update evotor_meta, set status=active if was pending
  5. If not found: create User(status=pending, role=user, evotor_user_id=..., password_hash=None)
  6. Generate invite_token = secrets.token_urlsafe(32), invite_expires = now + 48h
  7. Dispatch send_email_task.delay(email, "Приглашение в ЭвоСинк", html) via Celery
  8. Generate a random api_token = secrets.token_urlsafe(32), upsert into evotor_connections
  9. Return {"userId": payload.userId, "token": api_token}

/user/verify flow:

  1. Verify Bearer; parse {userId, username, password}
  2. Find user by email OR phone (username field)
  3. Check: password_hash not None, status not suspended, verify_password(password, hash)
  4. Return {"userId": user.evotor_user_id, "token": evotor_connection.api_token}

/user/token flow:

  1. Verify Bearer; parse {userId, token}
  2. Find user by evotor_user_id; upsert evotor_connections.access_token = token
  3. Return HTTP 200 {}

Admin Panel (web/routes/admin.py) — all require Depends(require_role("admin", "system"))

Method Path
GET /admin/users — paginated list with role/status/search filters
GET /admin/users/{id} — user card, evotor_meta JSON display
POST /admin/users/{id}/activate — set status=active
POST /admin/users/{id}/suspend — set status=suspended
POST /admin/users/{id}/reset-password — generate token, dispatch email task
POST /admin/users/{id}/send-invite — regenerate invite_token, dispatch email task
POST /admin/users/{id}/edit — update name/email/phone/role
POST /admin/users/{id}/delete — hard delete (system only)
GET /admin/roles — role + permission list (system only)
POST /admin/roles/{id}/permissions — set permissions for role

User Profile (web/routes/profile.py)

Extend existing profile card with: role, status, Evotor connection info, "Confirm email" action (if is_email_confirmed=False).

Auth/Invite (new)

Method Path
GET/POST /invite?token=... — Evotor-invited user sets password + activates account

Notifications Layer

# base.py
class EmailProvider(ABC):
    @abstractmethod
    def send(self, to: str, subject: str, html_body: str) -> None: ...

class SMSProvider(ABC):
    @abstractmethod
    def send(self, to: str, text: str) -> None: ...

# console.py — prints formatted block to stdout (like Node.js "="*40 pattern)
# registry.py — factory: EMAIL_PROVIDER env var selects implementation
# tasks.py
@celery_app.task(queue="notifications")
def send_email_task(to, subject, html_body): get_email_provider().send(...)

@celery_app.task(queue="notifications")
def send_sms_task(to, text): get_sms_provider().send(...)

Add to docker-compose worker command: --queues=default,sync,health,notifications


RBAC Middleware

# auth/rbac.py
def require_role(*roles):
    def dep(request, db=Depends(get_db)):
        user = get_current_user(request, db)
        if user.role.value not in roles: raise HTTPException(403)
        return user
    return Depends(dep)

def require_permission(permission_name):
    def dep(request, db=Depends(get_db)):
        user = get_current_user(request, db)
        if user.role == UserRoleEnum.system: return user  # bypass
        # walk user_roles → role_permissions → permissions
        ...
    return Depends(dep)

Migration Strategy

0002: Full schema (conditionally — IF NOT EXISTS / ADD COLUMN IF NOT EXISTS for live DBs with Node.js tables). Includes ALTER COLUMN password_hash to nullable. Adds api_token to evotor_connections. Adds role, status, evotor_user_id, evotor_meta, invite_token, invite_expires, phone_otp, phone_otp_expires to users.

0003: RBAC tables (roles, permissions, role_permissions, user_roles). Seeds three default Role rows and baseline permission set (admin.users.view, admin.users.edit, admin.users.delete, admin.roles.manage).

0004 (optional): Seeds a system user from SYSTEM_USER_EMAIL / SYSTEM_USER_PASSWORD env vars.


Order of Implementation

  1. Modelsuser.py, connections.py, rbac.py, __init__.py
  2. Migrations — 0002, 0003 (run alembic upgrade head to verify)
  3. Auth foundationauth/password.py, auth/session.py, templates_env.py, update main.py
  4. Core auth routesroutes/auth.py, routes/reset.py + templates (base.html, login, register, etc.)
  5. Notificationsnotifications/ package + tasks.py + update celery_app.py
  6. Invite flowroutes/invite.py + invite.html
  7. Evotor webhooksroutes/evotor_webhooks.py (the most novel piece)
  8. Profile + Connections — port from Node.js 854c912
  9. RBAC middlewareauth/rbac.py
  10. Admin panelroutes/admin.py + admin templates
  11. Catalog + Sync — port remaining routes and Celery tasks from Node.js

Verification

  1. alembic upgrade head — all migrations run clean on a fresh DB
  2. POST /user/create with curl + Bearer token → check user created in DB, invite email printed to console
  3. GET /invite?token=<token> → set password → check status=active, is_email_confirmed=True
  4. POST /login with the set password → session created, redirect to /profile
  5. POST /user/verify with username+password → returns {userId, token}
  6. POST /user/token with {userId, token} → 200, evotor_connection updated
  7. Login as admin user, visit /admin/users — user list renders
  8. Admin activates/suspends a user — status changes in DB
  9. POST /login as suspended user → rejected
  10. uvicorn web.main:app --reload — no import errors, health check returns 200