CustomBlock: ERP & Headless CMS
Framework-as-a-service with Server-Driven UI
What is this project?
CustomBlock is a modular, multi-tenant B2B backend operating on a 'framework-as-a-service' model. A shared core handles authentication, audit logging, and UI generation, while individual tenant modules (like this portfolio's Headless CMS or an inventory ERP) plug into the framework by providing only their data models — eliminating all boilerplate code.
The Challenge
The goal was to build a multi-tenant architecture with strict separation between the core framework and customer code, enabling zero-downtime framework upgrades via versioned Docker images. I also wanted to eliminate manual HTML authoring by engineering a Server-Driven UI capable of generating forms, data grids, and APIs dynamically by reading database metadata.
Architecture
Framework and tenant code are kept architecturally separate at every level — in the module system, in the Docker image, and in the deployment pipeline. This makes it possible to update the core framework across all tenants by releasing a new versioned Docker image, without touching customer code.
- api/dynamic_crud.py
- auth/ & security/rbac.py
- ui/ (builder, components)
- models/ (User, AuditLog)
- gdpr.py · flash.py
- main.py (create_app)
- models.py
- router.py
- menu.py
- migrations/
- .env
Tech Stack
| Language | Python 3.14 |
| Web Framework | FastAPI (async) |
| ORM / Driver | SQLAlchemy 2.x — aiomysql |
| Database | MySQL 8.4 |
| Templates | Jinja2 — ChoiceLoader |
| UI Framework | Tabler + Tabulator.js |
| Auth | JWT HS256 — HttpOnly cookies |
| Password Hashing | bcrypt (12 rounds) |
| Migrations | Alembic |
| Containerisation | Docker + Docker Compose |
| Settings | Pydantic v2 BaseSettings |
Dynamic CRUD Engine
register_crud_routes() generates 8 standard routes for any SQLAlchemy model with a single function call. A customer registers all its resources in a loop — the entire admin interface for 8 models takes approximately 10 lines of Python.
| Method | Route | Purpose |
|---|---|---|
| GET | /api/tabulator{path} | Paginated JSON endpoint for Tabulator.js |
| GET | {path} | List page with TabulatorComponent |
| GET | {path}/new | Create form |
| POST | {path}/new | Create record → flash message → redirect |
| GET | {path}/{pk} | Read-only detail page (DetailComponent) |
| GET | {path}/{pk}/edit | Edit form with Delete + optional GDPR Anonymize |
| POST | {path}/{pk}/edit | Update record → flash message → redirect |
| POST | {path}/{pk}/delete | Delete record → flash message → redirect |
Server-Driven UI Engine
No HTML is written per resource. UI is expressed as a tree of Python dataclasses attached to SQLAlchemy column info metadata. The engine reads this metadata and produces complete, interactive admin pages. Python never builds HTML strings — all rendering is delegated to Jinja2.
Component Tree
Column Metadata drives everything
Adding a database column with the right info={} dict instantly updates the create/edit forms, the Tabulator list view, and the public API response schema — without touching any frontend code.
Security Architecture
JWT (HttpOnly)
HS256 tokens in HttpOnly, SameSite=Lax cookies. Dual-mode login: browser clients receive a Set-Cookie redirect; API clients receive {"access_token": ...} JSON — both from the same endpoint.
RBAC
Factory function returning FastAPI-injectable dependency functions. Pre-built guards: allow_admin, allow_authenticated. Navigation items are filtered server-side per role — no empty dropdowns for restricted users.
Automated Audit Log
SQLAlchemy event hooks (after_insert, before_update, before_delete) silently capture every data change using ContextVars. Entries are written in the same DB transaction as the change — no phantom logs on rollback.
GDPR Anonymization
5-step Article 17 engine: scramble PII fields, scrub the record's audit history, anonymize historical actions by the user, and scan all audit logs for the subject's email. Triggered via a modal-confirmed button on the edit form.
Active Tenants
Eight interconnected models: Products, Categories, Warehouses, Suppliers, Purchase Orders, Customers, Sales Orders. Three navigation dropdowns. The entire admin interface is registered in approximately 10 lines of Python.
Eight multilingual (IT/EN) models. Public API with server-side language flattening, draft filtering, absolute URL resolution, and a dedicated cross-origin CV download endpoint. Powers this portfolio site.
Docker Architecture
The Dockerfile copies only core/ — customer directories are deliberately excluded. The result is a versioned, immutable cb-core:<version> image. Each customer's .env pins its own CORE_VERSION, allowing staged rollouts. Two customers running simultaneously share nothing: separate networks, separate volumes, separate databases.
cbenv.sh — Developer CLI
Sourced into the shell (source cbenv.sh), it exposes the cbdev command. A pre-flight check validates that the required cb-core image exists locally before any stack starts — no silent pulls or mid-startup failures.
-
cbdev build <version>— Build a versioned cb-core image from the local Dockerfile -
cbdev <customer> start— Render Compose template and start the tenant stack -
cbdev <customer> migrate— Run alembic upgrade head inside the container -
cbdev <customer> bash— Open a shell inside the running app container -
cbdev <customer> remove— Stop the stack and delete its volumes (destructive)