docs: add implementation plan for Evotor user lifecycle + RBAC + admin panel
This commit is contained in:
296
docs/PLAN_evotor_user_lifecycle.md
Normal file
296
docs/PLAN_evotor_user_lifecycle.md
Normal 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
|
||||
Reference in New Issue
Block a user