Dokument: evaluacija možnosti za multi-tenant verzijo aplikacije Radio klub Člani Datum: 2026-02-24 | Posodobljeno: 2026-03-31 (ažurirano za v1.26) | Status: evaluacija, ni obveza implementacije
ZRS (Zveza radioamaterjev Slovenije) bi gostila centralno instanco za 20–100 radioklubov na Linux x64 strežniku.
Potrjene zahteve:
- 20–100 tenantov (radioklubov)
- Brez cross-tenant poizvedb – klubi so popolnoma neodvisni
- Podatkovna izolacija je kritična (GDPR)
- Portabilnost je zaželena: klub mora moći vzeti svoje podatke in zagnati lastno standalone instanco
- Super-admin tier: samo kreiranje tenantov in admin računov, brez dostopa do podatkov
- Arhitekturne variante
- Primerjalna tabela
- Priporočena arhitektura (Option B)
- Obstoječi middleware stack
- Podatkovni model – multi-tenant implikacije
- Alternativa za večjo skalo (Option C)
- Super-admin tier
- URL in routing strategija
- Ocena dela po opcijah
- Varnost in GDPR
- Docker in infrastruktura
- Portabilnost – exit strategija
- Tveganja in odprta vprašanja
Vsak radioklub dobi ločen Docker vsebnik z lastno instanco aplikacije.
nginx
├── s59dgo.clanstvo.zrs.si → container radioklub-s59dgo (port 8001)
├── s59abc.clanstvo.zrs.si → container radioklub-s59abc (port 8002)
└── s59xyz.clanstvo.zrs.si → container radioklub-s59xyz (port 8003)
Prednosti:
- Nič kode ni treba spremeniti – obstoječa aplikacija deluje brez modifikacij
- Popolna izolacija na ravni procesa, OS in datotečnega sistema
- En klub ne more vplivati na delovanje drugega (memory leak, crashed process, itd.)
- Posodobitev enega kluba ne vpliva na druge
- Portabilnost je trivialna – vsak klub ima svojo
./data/clanstvo.db
Slabosti:
- Operativno breme: 50 klubov = 50 vsebnikov, 50
.envdatotek, 50 nginx location blokov - Poraba RAM: ~50–100 MB per instanca × 50 = 2.5–5 GB samo za aplikacije
- Ni centralnega super-admin vmesnika – vse se konfigurira ročno ali s skripti
- Posodobitev kode zahteva rebuild vseh vsebnikov
Ocena dela: 1–3 dni (samo infrastruktura, brez kode)
Ena aplikacija, vsak klub ima svojo data/<tenant_id>/clanstvo.db. Tenant se identificira iz subdomene ali URL poti.
nginx (wildcard subdomain)
│
▼
FastAPI (ena instanca, port 8000)
│
├── TenantMiddleware → prebere subdomain → tenant_id
│
├── DynamicDBMiddleware → odpre/cachira SQLiteEngine za tenant
│
├── request.state.tenant_id = "s59dgo"
├── request.state.db = Session(s59dgo_engine)
│
data/
├── s59dgo/clanstvo.db
├── s59abc/clanstvo.db
└── s59xyz/clanstvo.db
Prednosti:
- Popolna GDPR izolacija: vsaka baza je fizično ločena datoteka
- Portabilnost: klub vzame svojo
.dbin zažene standalone instanco brez sprememb - Ena aplikacija = en proces, en Docker vsebnik, ena posodobitev kode
- Obstoječa arhitektura (SQLAlchemy, Jinja2, SQLite) se ne zamenja
- Backup per tenant je preprost (
cp data/s59dgo/clanstvo.db ...)
Slabosti:
- Zmerna količina kode: tenant middleware, dynamic DB routing, super-admin UI
- SQLite connection pool pri 50+ sočasnih tenantih zahteva premišljeno upravljanje
- SQLite WAL mode priporočen za boljšo sočasnost
- Nič cross-tenant poizvedb (kar je v tem primeru zahteva, ne slabost)
Ocena dela: 8–11 tednov
Ena PostgreSQL baza, vsak tenant dobi svojo shemo (s59dgo.*, s59abc.*).
FastAPI
│
├── TenantMiddleware → set search_path = s59dgo
│
PostgreSQL
├── schema: s59dgo → clani, clanarine, aktivnosti, ...
├── schema: s59abc → clani, clanarine, aktivnosti, ...
└── schema: super_admin → tenanti, super_admin_log
Prednosti:
- Industrijski standard za multi-tenancy pri tej skali
- Izolacija na ravni DB sheme – PostgreSQL to nativno podpira
- Boljša sočasnost kot SQLite pri večjem prometu
- Backup per tenant z
pg_dump --schema=s59dgo - Potencial za kasnejše cross-tenant poizvedbe če bi bila potrebna
Slabosti:
- Velik odmik od obstoječe arhitekture: SQLite → PostgreSQL migracija je obsežna
- Potreben Alembic ali lastna migracijska logika per shemo
- Portabilnost je slabša: klub ne more "vzeti" PostgreSQL sheme in zagnati standalone SQLite instanco brez konverzijskega koraka
- Kompleksnejša lokalna razvojna okolja
- Operativni overhead: PostgreSQL vzdrževanje, backup, replication
Ocena dela: 15–20 tednov
Vsi klubi v isti bazi, vsaka tabela dobi tenant_id kolono.
CREATE TABLE clani (
id INTEGER PRIMARY KEY,
tenant_id TEXT NOT NULL, -- ← dodan
priimek TEXT, ime TEXT, ...
);Prednosti:
- Najmanj infrastrukturnih sprememb
- Enostavna implementacija
Slabosti:
- Kritično za GDPR: funkcionalna izolacija brez fizične ločenosti
- Vsak bug v query-ju (pozabljen WHERE tenant_id = ?) razkrije podatke drugega kluba
- Backup enega kluba zahteva filtriranje iz skupne baze
- Portabilnost zahteva kompleksen izvoz
- Ni primerno za to aplikacijo glede na zahtevo po kritični izolaciji
Priporočilo: izključiti Option D.
| Kriterij | Option A (več instanc) | Option B (ena instanca, ločene SQLite) | Option C (PostgreSQL sheme) | Option D (shared tabele) |
|---|---|---|---|---|
| GDPR izolacija | ✅ Fizična (OS level) | ✅ Fizična (datoteka) | ✅ Fizična (shema) | |
| Portabilnost | ✅ Trivialna | ✅ Odlična | ❌ Kompleksna | |
| Kode za spremeniti | ✅ Nič | ❌ Obsežno (migracija DB) | ||
| Ops pri 50 klubih | ❌ 50 vsebnikov | ✅ 1 vsebnik | ✅ 1 vsebnik + PostgreSQL | ✅ 1 vsebnik |
| RAM poraba | ❌ ~3–5 GB | ✅ ~200–400 MB | ✅ ~200–400 MB + PG | ✅ nizka |
| Backup per tenant | ✅ Trivialen | ✅ Preprost | ❌ Kompleksen | |
| Sočasnost | ✅ Ločeni procesi | ✅ PostgreSQL native | ✅ PostgreSQL | |
| Obseg dela | 1–3 dni | 8–11 tednov | 15–20 tednov | 8–11 tednov |
| Priporočilo | Za hiter start | Optimalno | Za 100+ klubov | Izključiti |
Glede na zahteve (20–100 klubov, GDPR kritično, portabilnost zaželena, brez cross-tenant poizvedb) je Option B optimalna izbira:
- Ohranja vse prednosti obstoječe arhitekture (SQLite, SQLAlchemy, Jinja2)
- Fizična izolacija baz zadosti GDPR zahtevam
- Portabilnost je inherentna – klub vzame
.dbdatoteko - Ena instanca = enostavno vzdrževanje in posodobitve
- Obseg dela je realen
class TenantMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Iz subdomene: s59dgo.clanstvo.zrs.si → "s59dgo"
host = request.headers.get("host", "")
tenant_id = host.split(".")[0] if "." in host else None
# Validacija: tenant mora obstajati
if tenant_id and tenant_id != "admin":
if not _tenant_exists(tenant_id):
return Response("Klub ne obstaja", status_code=404)
request.state.tenant_id = tenant_id
else:
request.state.tenant_id = None # super-admin kontekst
return await call_next(request)# Vsak tenant ima svojo SQLAlchemy engine instanco
_tenant_engines: dict[str, Engine] = {}
def _run_tenant_migrations(engine: Engine) -> None:
"""Zažene Alembic migracije za tenant-specifičen engine.
Uporablja enak pristop kot obstoječa _run_migrations() v main.py:
obstoječe baze brez alembic_version se označijo kot revizija 001,
nato se aplicirajo vse novejše migracije do head.
"""
ini_path = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "alembic.ini"))
cfg = AlembicConfig(ini_path)
# Alembic uporabi dynamic engine namesto privzetega iz alembic.ini
cfg.attributes["connection"] = engine.connect()
inspector = sa_inspect(engine)
tables = inspector.get_table_names()
if "alembic_version" not in tables and "clani" in tables:
alembic_command.stamp(cfg, "001")
alembic_command.upgrade(cfg, "head")
def get_tenant_engine(tenant_id: str) -> Engine:
if tenant_id not in _tenant_engines:
db_path = f"data/{tenant_id}/clanstvo.db"
os.makedirs(f"data/{tenant_id}", exist_ok=True)
engine = create_engine(f"sqlite:///{db_path}", ...)
_run_tenant_migrations(engine) # Alembic migracije (001–008+)
_tenant_engines[tenant_id] = engine
return _tenant_engines[tenant_id]
def get_db(request: Request):
engine = get_tenant_engine(request.state.tenant_id)
db = SessionLocal(bind=engine)
try:
yield db
finally:
db.close()Opomba: Aplikacija ne uporablja
Base.metadata.create_all(). Vse tabele se kreirajo izključno prek Alembic migracij (trenutno 8 revizij: 001–008). Za novega tenanta se zaženejo vse migracije od začetka, za obstoječega pa samo manjkajoče.
Obstoječe Depends(get_db) v vseh routerjih deluje brez sprememb – samo implementacija get_db se zamenja, da vrne tenant-specifično sejo. To je ključna prednost obstoječe arhitekture z dependency injection.
Session piškotki morajo biti ločeni per tenant. Enostavna rešitev: vsak tenant dobi ločen SECRET_KEY (generiran ob kreiranju tenanta in shranjen v super-admin bazi) ali pa se v session key doda tenant prefix:
# Opcija A: ločen secret per tenant (boljše)
secret = get_tenant_secret(tenant_id) # iz super-admin baze
app.add_middleware(SessionMiddleware, secret_key=secret, ...)
# Opcija B: tenant v session key (zadostuje za začetek)
session_key = f"{tenant_id}:{session_data['uporabnik']}"Ostanejo enaki – delujejo per-request, torej so tenant-agnostični.
Vsak tenant ima svojo audit_log tabelo v svoji bazi – izolacija je inherentna.
UpravljanjeClanstva/
├── app/
│ ├── main.py – + TenantMiddleware, DynamicDBMiddleware
│ ├── tenant.py – nova: tenant management helpers
│ ├── super_admin/ – nova: super-admin routerji in templates
│ │ ├── router.py
│ │ └── templates/
│ ├── database.py – posodobljen: dynamic engine per tenant
│ └── ... – ostalo nespremenjeno
├── data/
│ ├── super_admin.db – super-admin baza (tenanti, super_admin_log)
│ ├── s59dgo/
│ │ └── clanstvo.db
│ ├── s59abc/
│ │ └── clanstvo.db
│ └── s59xyz/
│ └── clanstvo.db
├── docker-compose.yml – posodobljen (1 vsebnik)
└── nginx.conf – wildcard subdomain config
Aplikacija (v1.26) ima 6 middleware-ov z natančno določenim vrstnim redom. Multi-tenant implementacija mora ta vrstni red ohraniti in novi TenantMiddleware/DynamicDBMiddleware umestiti na pravilno mesto.
# 1. SecurityHeadersMiddleware – varnostni HTTP headerji (X-Frame-Options, CSP, ...)
# 2. InactivityTimeoutMiddleware – 30 min neaktivnosti → odjava (zahteva session)
# 3. SessionMiddleware – Starlette session (bcrypt, same_site=strict)
# 4. ContentSizeLimitMiddleware – POST/PUT/PATCH > 1 MB → HTTP 413
# 5. KlubContextMiddleware – request.state.klub_ime/oznaka iz DB (60s cache)
# 6. ProxyHeadersMiddleware – X-Forwarded-For / X-Real-IP# Predlagan vrstni red z novimi middleware-i:
app.add_middleware(SecurityHeadersMiddleware)
app.add_middleware(InactivityTimeoutMiddleware)
app.add_middleware(SessionMiddleware, ...)
app.add_middleware(ContentSizeLimitMiddleware)
app.add_middleware(KlubContextMiddleware) # ← mora postati per-tenant (dynamic DB)
app.add_middleware(DynamicDBMiddleware) # ← NOVO: odpre tenant DB sejo
app.add_middleware(TenantMiddleware) # ← NOVO: prebere subdomain → tenant_id
app.add_middleware(ProxyHeadersMiddleware, ...)| Middleware | Sprememba za multi-tenant |
|---|---|
SecurityHeadersMiddleware |
Brez sprememb – tenant-agnostičen |
InactivityTimeoutMiddleware |
Brez sprememb – deluje per-session |
SessionMiddleware |
Session izolacija per tenant (ločen secret ali cookie domain) |
ContentSizeLimitMiddleware |
_UPLOAD_PATHS morajo upoštevati morebitni tenant prefix |
KlubContextMiddleware |
Kritično: 60s cache mora postati per-tenant (_cache dict z tenant_id ključem), ker vsak klub ima svoje ime/oznako |
ProxyHeadersMiddleware |
Brez sprememb |
Od prvotne evaluacije (v1.11) je podatkovni model znatno zrasel. Vse spodnje tabele so per-tenant (vsaka baza vsebuje polni nabor tabel).
| Model | Od verzije | Alembic | Multi-tenant opomba |
|---|---|---|---|
LoginPoizkus |
v1.13 | 003 | Per-tenant baza → rate limiting je avtomatsko izoliran per klub |
ClanVloga |
v1.15 | 004 | Per-tenant – vloge članov z zgodovino (datum_od/datum_do) |
EmailPredloga |
v1.17 | 005+007+008 | Per-tenant – vsak klub si prilagodi predloge. Seed 6 privzetih ob kreiranju tenanta |
| Indeksi (clani, clanarine, aktivnosti) | v1.19 | 006 | Per-tenant – performance indeksi |
| Funkcionalnost | Od verzije | Konfiguracija | Multi-tenant implikacija |
|---|---|---|---|
| UPN QR koda | v1.16 | Nastavitev tabela (IBAN, BIC, namen ...) |
Per-tenant – vsak klub ima svoje bančne podatke |
| Email obvestila | v1.17–v1.20 | SMTP nastavitve v Nastavitev |
Per-tenant – vsak klub ima svoj SMTP strežnik |
| Članska kartica | v1.23 | kartica_polja v Nastavitev |
Per-tenant – vsak klub prilagodi polja kartice |
| AKOS uvoz RD | v1.18, v1.21 | Zunanji API klic | Tenant-agnostičen (API je enak za vse) |
| Excel izvoz (filtrirani) | v1.21 | — | Per-tenant (izvozi iz tenant baze) |
| Bulk email filtri | v1.19 | — | Per-tenant (filtrira iz tenant baze) |
Vsaka tenant baza mora ob kreiranju preteči vseh 8 migracij:
001_initial_schema.py – jedro: clani, clanarine, aktivnosti, skupine, uporabniki, nastavitve, audit_log, zaupljive_naprave
002_zaupljive_naprave.py – 2FA zaupljive naprave
003_login_poskusi.py – persistentni rate limiting (LoginPoizkus)
004_clan_vloge.py – vloge članov z zgodovino (ClanVloga)
005_email_predloge.py – email predloge (EmailPredloga)
006_indeksi.py – performance indeksi (ix_clani_aktiven, ix_clanarine_leto, ix_aktivnosti_leto)
007_email_predloge_qr.py – vkljuci_qr stolpec na EmailPredloga
008_email_predloge_kartica.py – prilozi_kartico stolpec na EmailPredloga
Portabilnost: Ker tenant baza vsebuje celoten podatkovni model vključno z nastavitvami, email predlogami, UPN konfiguracijo in članskimi karticami, je portabilnost boljša kot ob prvotni evaluaciji. Klub vzame
.dbin dobi 100% funkcionalnost brez ročne konfiguracije.
Če bi ZRS kdaj prerasla 100 klubov ali potrebovala cross-tenant analitiko, bi bila selitev na PostgreSQL s shemami smiselna.
- SQLAlchemy dialect:
sqlite://→postgresql://(večinoma kompatibilno) _run_migrations()prilagoditi za Alembic migration per PostgreSQL shemacreate_schemapri kreiranju tenanta:CREATE SCHEMA s59dgo; SET search_path = s59dgo;- Connection string per tenant:
postgresql://user:pass@host/clanstvo?options=-c search_path=s59dgo - Async SQLAlchemy priporočen pri PostgreSQL za boljšo sočasnost
| SQLite specifika | PostgreSQL ekvivalent |
|---|---|
INTEGER PRIMARY KEY (autoincrement) |
SERIAL ali BIGSERIAL |
BOOLEAN kot 0/1 |
nativni BOOLEAN |
func.now() |
dela enako |
text() raw SQL migracije |
bolj strogo tipiziran SQL |
REAL za float |
DOUBLE PRECISION |
| Brez sheme po privzetem | SET search_path = tenant_id |
Portabilnost se izgubi: klub ne more vzeti PostgreSQL sheme in jo neposredno poganjati kot SQLite. Potreben bi bil pg_dump | sqlite-convert pipeline. Za klube, ki bi hoteli standalone, bi bilo treba ohraniti SQLite izvoz (prek obstoječega Excel backup ali posebnega DB dump endpointa).
Super-admin je popolnoma ločen od tenant adminov. Dostopa samo do meta-podatkov (seznam tenantov), nikoli do vsebinskih podatkov kluba.
tenanti
├── id (TEXT PK) – klicni znak: "s59dgo"
├── ime – polno ime kluba
├── aktiven (BOOL)
├── ustvarjen_cas (DateTime)
└── opombe
super_admin_log
├── id (PK)
├── cas
├── akcija – "tenant_ustvarjen", "tenant_deaktiviran", "admin_kreiran"
└── opis
| Akcija | Opis |
|---|---|
| Ustvari tenant | Vnesi klicni znak + ime kluba → ustvari mapo data/<id>/, inicializira DB, kreira prvega admin računa |
| Deaktiviraj tenant | Blokira dostop (HTTP 403 za vse zahteve tega tenanta), DB ostane |
| Reaktiviraj tenant | Obnovi dostop |
| Pregled tenantov | Seznam klubov z datumom kreacije in statusom (brez vsebinskih podatkov!) |
| Audit log | Pregled super-admin akcij (brez vpogleda v tenant audit loge) |
- Članov posameznega kluba
- Plačil, aktivnosti, skupin
- Uporabniških računov v klubu (razen lastnega kreiranja prvega admina)
- Audit loga posameznega kluba
Ta omejitev je arhitekturno zagotovljena: super-admin get_db dependency vrne super-admin bazo, nikoli tenant baze.
Super-admin bi bil dostopen na ločeni subdomeni:
https://admin.clanstvo.zrs.si
Z lastno prijavo (ločeni Uporabnik zapisi v super_admin.db). Session piškotki za super-admin in tenant admin so ločeni (ločeni secret_key ali ločeni cookie name).
s59dgo.clanstvo.zrs.si → klub S59DGO
s59abc.clanstvo.zrs.si → klub S59ABC
admin.clanstvo.zrs.si → super-admin
Nginx konfiguracija (wildcard):
server {
listen 443 ssl http2;
server_name *.clanstvo.zrs.si;
ssl_certificate /etc/letsencrypt/live/clanstvo.zrs.si/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/clanstvo.zrs.si/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Let's Encrypt wildcard certifikat zahteva DNS-01 challenge (ne HTTP-01). Potreben je DNS provider z API-jem (npr. Cloudflare, Route53) ali ročna obnova. Certbot to podpira prek pluginov.
Prednosti subdomene:
- Čisti URL-ji:
s59dgo.clanstvo.zrs.si/clani(ne/s59dgo/clani) - Preprost TenantMiddleware (parsiranje subdomene)
- Jasna vizualna ločitev za uporabnike
clanstvo.zrs.si/klubi/s59dgo/clani
clanstvo.zrs.si/klubi/s59abc/clani
Prednosti:
- Enostavnejši certifikat (standardni, brez wildcard)
- Enostavnejši DNS
Slabosti:
- Vsi FastAPI routerji dobijo prefix
/klubi/{tenant_id}/→ obsežne spremembe vseh URL-jev - Težje ločiti super-admin od tenant dostopov
- Manj intuitiven URL za klube
Priporočilo: Subdomena je boljša izkušnja, wildcard certifikat ni problematičen na modernem DNS.
| Naloga | Ocena |
|---|---|
| Nginx config za wildcard subdomain | 0.5 dneva |
| Bash skripta za kreiranje nove instance | 1 dan |
| Dokumentacija postopka | 0.5 dneva |
| Skupaj | ~2 dneva |
Ni super-admin vmesnika – vse se dela ročno s skripti ali docker-compose.
| Naloga | Ocena |
|---|---|
TenantMiddleware + DynamicDBMiddleware |
1 teden |
Posodobitev get_db dependency + testiranje |
3 dni |
| Alembic programatski API za dynamic tenant engine | 2 dni |
| Session izolacija per tenant | 3 dni |
| Super-admin baza + modeli | 3 dni |
| Super-admin UI (seznam tenantov, kreiranje) | 1 teden |
| Tenant provisioning (kreiranje mape, DB, migracije, prvega admina, seed predlog) | 4 dni |
| Nginx wildcard + wildcard certifikat | 2 dni |
KlubContextMiddleware per-tenant cache |
1 dan |
ContentSizeLimitMiddleware prilagoditev za tenant poti |
0.5 dneva |
| SMTP nastavitve per-tenant (email obvestila) | 1 dan |
| End-to-end testiranje (10+ tenantov, vključno UPN/email/kartica) | 1.5 tedna |
| Dokumentacija + deployment guide | 3 dni |
| Skupaj | ~8–11 tednov |
Opomba (posodobljeno 2026-03-31): Prvotna ocena 6–9 tednov je bila na osnovi v1.11 (brez email obvestil, UPN QR, članskih kartic, vloge članov). Funkcionalnosti dodane v v1.13–v1.26 (8 Alembic migracij, 6 email predlog seed, SMTP konfiguracija, kartica_polja) povečajo obseg tenant provisioninga in testiranja.
| Naloga | Ocena |
|---|---|
| Vse iz Option B | 8–11 tednov |
| SQLite → PostgreSQL migracija | 3 tedni |
| Alembic setup per shema | 1 teden |
| Async SQLAlchemy (opcijsko za perf) | 1 teden |
| PostgreSQL Docker + backup setup | 3 dni |
| Skupaj | ~15–20 tednov |
| Zahteva | Option B (SQLite) | Option C (PG sheme) |
|---|---|---|
| Fizična izolacija podatkov | ✅ Ločene .db datoteke |
✅ Ločene sheme |
| Backup samo za lasten klub | ✅ Trivialen | ✅ pg_dump --schema |
| Brisanje tenanta (GDPR čl. 17) | ✅ rm -rf data/s59dgo/ |
✅ DROP SCHEMA s59dgo CASCADE |
| Super-admin brez vpogleda v podatke | ✅ Arhitekturno zagotovljeno | ✅ Arhitekturno zagotovljeno |
| Revizijska sled per klub | ✅ Vsak klub ima svojo audit_log |
✅ Isto |
| Šifriranje v mirovanju (encryption at rest) |
Aplikacijska-nivojska enkripcija podatkov (šifriranje posameznih stolpcev v DB) ni priporočena za ta primer:
- Dodaja kompleksnost brez bistvene prednosti (aplikacija mora podatke vseeno dešifrirati za prikaz)
- Otežuje iskanje in filtriranje
- Ključi morajo biti nekje shranjeni – prenesejo problem drugam
Priporočena alternativa: Linux disk encryption (LUKS) na strežniku za encryption at rest. To je transparentno za aplikacijo, zagotavlja varstvo pri fizični kraji strežnika, in je standardna praksa.
# Primer: šifriran volumen samo za data/
cryptsetup luksFormat /dev/sdb
cryptsetup open /dev/sdb clanstvo-data
mkfs.ext4 /dev/mapper/clanstvo-data
mount /dev/mapper/clanstvo-data /opt/radioklub/data- Tenant enumeration: Middleware mora preprečiti, da napadalec ugotovi katere subdomene/tenant IDs obstajajo. Neobstoječ tenant → generičen 404 brez razkritja.
- Session cookie contamination: Session za
s59dgone sme biti veljavna zas59abc. Zagotovljeno z ločenimi secret_key ali cookiedomainatributom (.s59dgo.clanstvo.zrs.si). - Path traversal v tenant_id:
tenant_idmora biti validiran z allowlist regex (^[a-z0-9]{3,10}$) pred uporabo v file path-u. - Super-admin kompromitacija: Ker super-admin lahko kreira admin račune, je kompromitacija super-admin računa visoko tvegana. Priporočena obvezna 2FA za super-admin.
- Rate limiting per tenant: Od v1.13 rate limiting uporablja DB model
LoginPoizkus(persistenten, Alembic 003). V multi-tenant arhitekturi je izolacija inherentna – vsak tenant ima svojo bazo s svojologin_poskusitabelo. Ni potrebna dodatna logika za ločevanje.
services:
clanstvo:
build: .
container_name: radioklub-clanstvo-multi
ports:
- "127.0.0.1:8000:8000"
volumes:
- ./data:/app/data # vsebuje vse tenant baze
environment:
- SECRET_KEY_MASTER=${SECRET_KEY_MASTER} # za super-admin sejo
- ADMIN_GESLO=${ADMIN_GESLO} # za prvega super-admina
- OKOLJE=produkcija
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c",
"import urllib.request; urllib.request.urlopen('http://localhost:8000/login')"]
interval: 30s
timeout: 10s
retries: 3
# Opomba: za multi-tenant bo treba preverjati tenant-agnostičen endpoint
# (npr. /health ali super-admin /login), ne tenant-specifičnega.Volume struktura:
./data/
├── super_admin.db ← super-admin baza
├── s59dgo/
│ └── clanstvo.db
├── s59abc/
│ └── clanstvo.db
└── ...
Backup strategija:
# Dnevni backup vseh tenantov (cron)
#!/bin/bash
DATE=$(date +%Y%m%d)
BACKUP_DIR=/backup/radioklub/$DATE
mkdir -p $BACKUP_DIR
# Super-admin baza
cp /opt/radioklub/data/super_admin.db $BACKUP_DIR/
# Vse tenant baze
for tenant_dir in /opt/radioklub/data/*/; do
tenant=$(basename $tenant_dir)
if [ -f "$tenant_dir/clanstvo.db" ]; then
mkdir -p $BACKUP_DIR/$tenant
cp $tenant_dir/clanstvo.db $BACKUP_DIR/$tenant/
fi
done
# Kompresija
tar -czf $BACKUP_DIR/../radioklub_$DATE.tar.gz $BACKUP_DIR/
rm -rf $BACKUP_DIR
# Brisanje backupov starejših od 30 dni
find /backup/radioklub/ -name "*.tar.gz" -mtime +30 -delete| Vir | Minimalno | Priporočeno |
|---|---|---|
| RAM | 2 GB | 4 GB |
| CPU | 2 jedri | 4 jedra |
| Disk | 10 GB | 50 GB (z backupi) |
| OS | Ubuntu 22.04 LTS | Ubuntu 24.04 LTS |
Za primerjavo – Option A pri 50 tenantih bi zahteval ~3–5 GB RAM samo za procese.
Zahteva je, da klub, ki bi zapustil ZRS centralni sistem (ali bi ZRS ugasnila servis), vzame svoje podatke in zažene lastno instanco.
# Na ZRS strežniku: izvoz podatkov kluba
cp /opt/radioklub/data/s59dgo/clanstvo.db /tmp/s59dgo_export.db
# Klub dobi: s59dgo_export.db
# Klub postavi lastno instanco (obstoječa standalone aplikacija):
cp s59dgo_export.db data/clanstvo.db
docker compose up -d --buildRezultat: 100% podatkov, 100% funkcionalna aplikacija, brez nobene konverzije. To je ena od ključnih prednosti Option B.
# Izvoz iz PostgreSQL sheme
pg_dump --schema=s59dgo --no-owner radioklub_db > s59dgo_dump.sql
# Konverzija v SQLite (zahteva pgloader ali lasten skript)
pgloader s59dgo_dump.sql sqlite:///clanstvo.db
# Ali: aplikacija bi morala imeti SQLite export endpointManj zanesljivo, zahteva dodatna orodja in testiranje. Portabilnost je oslabljena.
| Tveganje | Verjetnost | Vpliv | Mitigacija |
|---|---|---|---|
| SQLite file locking pri sočasnih zahtevah istega tenanta | Srednja | Srednji | WAL journal mode (PRAGMA journal_mode=WAL) |
| Engine cache raste neomejeno pri 100+ tenantih | Nizka | Nizek | LRU cache z max 200 engine instancami, idle close |
| Path traversal napad prek tenant_id | Nizka | Visok | Regex allowlist validacija tenant_id |
| Wildcard SSL cert obnova (DNS-01 challenge) | Nizka | Visok | Cloudflare DNS plugin za Certbot, automatizirano |
| Session cookie napačen tenant (browser cache) | Nizka | Srednji | Cookie domain ekspliciten per subdomena |
- Kako se klubi registrirajo? Ročna kreacija s strani ZRS super-admina ali self-service obrazec?
- Plačljivost/freemium? Ni del te evaluacije, a vpliva na arhitekturo (activation flow, suspension).
- DNS upravljanje: Ali bo ZRS upravljala DNS za
clanstvo.zrs.siin wildcard? Ali bodo imeli klubi lastne domene (zahteva per-tenant certifikat)? - SLA in uptime: Kakšna je pričakovana razpoložljivost? En strežnik brez HA je single point of failure za vse klube.
- Testni/staging okolji: Ali bo vsak klub imel testno instanco ali samo produkcijsko?
Za 50+ klubov, ki se zanašajo na en strežnik, je priporočena vsaj:
- Dnevni off-site backup (rsync na drug strežnik ali S3-kompatibilen storage)
- Monitoring (Uptime Kuma ali podobno) z alertiranjem
- Dokumentiran recovery postopek (kako se obnovi iz backupa v <2h)
Visoka razpoložljivost (active-active cluster) je pri tej skali in naravi aplikacije verjetno pretirano – KISS princip velja.
Za 20–100 klubov s kritično GDPR izolacijo in zahtevo po portabilnosti je Option B (ena instanca, ločene SQLite baze) optimalna izbira.
Ocena dela: 8–11 tednov za izkušenega Python/FastAPI razvijalca, ki pozna obstoječo kodo.
Ključna prednost pred Option A je operativna preprostost (en vsebnik, en deployment), pred Option C pa ohranitev SQLite arhitekture in s tem trivialna portabilnost ter manjši obseg dela. Pred Option D jo ločuje fizična podatkovna izolacija, ki je pogoj za GDPR.
Priporočena pot:
1. Option B implementacija (8–11 tednov)
2. Wildcard subdomain + Certbot DNS-01 challenge
3. Super-admin UI (minimalen: kreiranje tenantov, prikaz statusa)
4. Samodejni backup skript (cron, off-site)
5. Monitoring + alertiranje
Dokument je evaluacija, ne implementacijska specifikacija. Pred pričetkom razvoja priporočam PoC (proof of concept) za TenantMiddleware + DynamicDBMiddleware v izoliranem branch-u.