docs: add implementation plan for Evotor user lifecycle + RBAC + admin panel

This commit is contained in:
2026-04-28 11:46:49 +03:00
parent 2df4898098
commit ba34adbbcf

View File

@@ -0,0 +1,296 @@
# 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`/`SMSProvider``ConsoleEmailProvider`/`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`
```python
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`
```python
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
```python
# 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
```python
# 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. **Models**`user.py`, `connections.py`, `rbac.py`, `__init__.py`
2. **Migrations** — 0002, 0003 (run `alembic upgrade head` to verify)
3. **Auth foundation**`auth/password.py`, `auth/session.py`, `templates_env.py`, update `main.py`
4. **Core auth routes**`routes/auth.py`, `routes/reset.py` + templates (`base.html`, login, register, etc.)
5. **Notifications**`notifications/` package + `tasks.py` + update `celery_app.py`
6. **Invite flow**`routes/invite.py` + `invite.html`
7. **Evotor webhooks**`routes/evotor_webhooks.py` (the most novel piece)
8. **Profile + Connections** — port from Node.js `854c912`
9. **RBAC middleware**`auth/rbac.py`
10. **Admin panel**`routes/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