Sari la conținut

GDocs — Platform Design

Acest conținut nu este încă disponibil în limba selectată.

GDocs (Registrul de Documente / gStack) — platformă centralizată de stocare și partajare a documentelor generate în procesul de prestare a serviciilor publice din Republica Moldova. Reduce documentele pe suport de hârtie, standardizează partajarea rezultatelor serviciilor publice, oferă o arhivă electronică și un API de schimb automat de documente pentru celelalte sisteme gStack.

Acest document este artefactul de design (sursa de adevăr pentru arhitectură). Modelul de entități trăiește în ../gdocs.jdl; DDL-ul derivat în db_schema.md; brief-ul de domeniu în draft/small_documentation.md.

Stiva urmează șablonul gStack (ca interdictii / gnotify): JHipster monolith (Spring Boot 3 + Angular), PostgreSQL (prod) / H2 (dev), Elasticsearch (căutare full-text), object-store MinIO/S3 (fișiere binare), RabbitMQ (cozi de procesare), Keycloak SSO. Pachet md.gov.gdocs, port 8095. i18n RO (nativ) + EN.


flowchart TB
subgraph Public["Utilizatori"]
anon["Cetățean ne-autentificat<br/>(căutare publică L1)"]
client["Client autentificat<br/>(PF / PJ / ONG / OGOV)"]
operator["Operator / Admin<br/>(user_read … admin_securizat)"]
end
subgraph GDocs["GDocs (gStack)"]
spa["Angular SPA<br/>(3 blocuri: Documente / Servicii / Administrare)"]
api["Spring Boot API<br/>REST + /api/v1 (mașină-mașină)"]
worker["Workeri asincroni<br/>(ingestie, OCR/transcriere, comprimare)"]
db[("PostgreSQL")]
es[("Elasticsearch<br/>full-text + fațete")]
store[("Object store<br/>MinIO / S3")]
mq[["RabbitMQ"]]
end
subgraph Ext["Ecosistem"]
kc["Keycloak SSO<br/>(realm gdocs)"]
msign["MSign<br/>(semnare — demo)"]
others["Alte sisteme gStack<br/>(interdictii, gnotify, …)"]
end
anon -->|HTTPS| spa
client -->|HTTPS| spa
operator -->|HTTPS| spa
spa --> api
others -->|API + token| api
api --> db
api --> es
api --> store
api --> mq
mq --> worker
worker --> store
worker --> es
worker --> db
api -->|OIDC| kc
spa -->|OIDC login| kc
api -->|semnare| msign

Trei planuri de utilizare (brief §Architectura):

  • Public — cetățeni ne-autentificați: doar căutarea documentelor publice (L1) — legislație, acte aprobate — după cuvinte-cheie / cod / autoritate emitentă, cu filtrări (an, emitent) și sortări (data apariției).
  • Client — PF/PJ/ONG/OGOV autentificați (Keycloak): documentele proprii, partajări, cereri de acces, transcrieri, șabloane.
  • Administrare — operatori; blocul de Administrare (Utilizatori / Roluri / Configurare / Jurnale / Loguri) este vizibil doar rolurilor de admin.

flowchart LR
subgraph FE["Frontend (Angular)"]
landing["Landing 3-blocuri"]
docs["Documente (mele / secrete / arhivă / upload)"]
tpl["Template-uri"]
serv["Servicii (transcriere / cereri / trash)"]
admin["Administrare"]
pub["Căutare publică"]
end
subgraph BE["Backend (Spring Boot, md.gov.gdocs)"]
rest["web.rest.*Resource (DTO)"]
v1["web.rest.v1.* (API mașină-mașină)"]
svc["service.*Service (+impl)"]
qsvc["service.*QueryService<br/>(filtre + gardă nivel acces)"]
acl["security.acl.*<br/>(L1/L2/L3 + roluri)"]
stori["service.storage.StorageService<br/>(MinIO/S3)"]
conv["service.conversie.*<br/>(OCR / transcriere)"]
arch["service.arhiva.*<br/>(comprimare)"]
audit["audit.AppendOnlyAuditGuard"]
repo["repository.* (JPA)"]
srepo["repository.search.* (ES)"]
end
FE -->|HTTPS + JWT| rest
rest --> svc
svc --> qsvc
svc --> acl
svc --> stori
svc --> repo
svc --> srepo
conv -.->|@RabbitListener| svc
arch -.->|@RabbitListener| svc
audit -.-> repo
v1 --> svc

Stratificare standard JHipster — entitățile nu părăsesc stratul de service (controllerele fac schimb de DTO). Reguli specifice GDocs:

  • Gardă de nivel de acces — orice listare/căutare trece prin *QueryService
    • security.acl care filtrează după NivelAcces și rolul curent. L3 nu este returnat niciodată unui operator fără nivel 3 (nici în liste, nici în ES, nici prin API).
  • Fișierele binare trăiesc în object-store; DB ține doar storageUri + metadate. Descărcarea trece prin backend (verifică ACL, apoi stream / URL pre-semnat).
  • Doar Document și Template sunt oglindite în Elasticsearch (textIndexat = text extras prin OCR/conversie → căutare full-text + fațete). ES este cablat manual (clase repository.search.* / service/search/* cu tipuri ES complet-calificate), NU prin JDL — pentru a păstra numele de entitate Document fără coliziunea cu adnotarea @Document din Spring Data Elasticsearch (convenția interdictii #4). La fel ca RabbitMQ, e o dependență adăugată în afara modelului generat.
  • Procesarea grea e asincronă (RabbitMQ): ingestie (extragere text, hash), transcriere/OCR, comprimare la arhivare. Workerii sunt separați de request.
  • EvenimentDocument este append-only (audit / Jurnale), impus de AppendOnlyAuditGuard (listener Hibernate care veto-ează UPDATE/DELETE).

erDiagram
CLIENT ||--o{ DOCUMENT : "proprietar"
EMITENT ||--o{ DOCUMENT : "emite"
APP_USER ||--o{ DOCUMENT : "încarcă"
TEMPLATE ||--o{ DOCUMENT : "generat din"
EMITENT ||--o{ TEMPLATE : "publică"
CLIENT ||--o{ APP_USER : "reprezentat de"
ROL ||--o{ APP_USER : "are rol"
DOCUMENT ||--o{ PARTAJARE_DOCUMENT : "partajat prin"
CLIENT ||--o{ PARTAJARE_DOCUMENT : "beneficiar"
DOCUMENT ||--o{ CERERE_ACCES : "vizat de"
CLIENT ||--o{ CERERE_ACCES : "solicitant"
DOCUMENT ||--o{ CONVERSIE : "sursă"
APP_USER ||--o{ CONVERSIE : "inițiază"
DOCUMENT ||--o{ SEMNATURA_DOCUMENT : "semnat prin"
DOCUMENT ||--o{ EVENIMENT_DOCUMENT : "audit append-only"
CLIENT {
bigint id PK
varchar tip "PF/PJ/ONG/OGOV"
varchar identificator "IDNP/IDNO (unic pe tip)"
varchar nume
}
DOCUMENT {
bigint id PK
varchar cod "unic, auto"
varchar denumire
varchar format "enum FormatDocument"
varchar storage_uri "cheie MinIO/S3"
varchar tip_securitate "STANDARD/HASH/PAROLA/CRIPTAT"
varchar nivel_acces "PUBLIC/PERSONAL/SECRET"
varchar statut "ACTIV/ARHIVAT/STERS"
date data_expirarii
text text_indexat "→ Elasticsearch"
text etichete "jsonb"
}
ROL {
bigint id PK
varchar cod "unic"
boolean poate_scrie
varchar nivel_maxim "NivelAcces"
}
PARTAJARE_DOCUMENT {
bigint id PK
varchar drept "CITIRE/SCRIERE/CITIRE_SCRIERE"
instant data_expirare
}
CERERE_ACCES {
bigint id PK
varchar drept_cerut
varchar statut "PENDING/APROBATA/RESPINSA"
}
CONVERSIE {
bigint id PK
varchar tip "pdf↔word / photo→…"
varchar statut
}

Entitățile complete și câmpurile sunt în gdocs.jdl; DDL-ul în db_schema.md.


Două axe ortogonale — nivelul documentului și rolul operatorului — plus partajări punctuale.

NivelCodCine poate vedea
L1PUBLICOricine, inclusiv ne-autentificați (căutarea publică).
L2PERSONALProprietarul (Client) + operatorii responsabili (admin), în limita nivelMaxim al rolului.
L3SECRET„Secret de stat”: DOAR clientul-proprietar. Niciun operator obișnuit; doar admin_securizat căruia i s-a acordat explicit nivel 3.
RolpoateScrienivelMaximDescriere (brief)
user_read✗PERSONALCitește documente L1/L2 în funcție de nivelul de acces.
user_write✓PERSONALCitește și modifică documente L1/L2.
admin✓PERSONALVede și modifică documentele (dar NU L3).
admin_securizat✓SECRETVede/modifică și L3 dacă i se acordă nivel 3.

Citirea este implicită pentru oricine poate accesa fișierul; poateScrie guvernează modificarea. Rolurile sunt un tabel (nu enum) ca un admin să le poată edita din blocul de Administrare fără re-deploy.

4.3 Decizia de acces (pseudo-cod, stratul de service)

Section titled “4.3 Decizia de acces (pseudo-cod, stratul de service)”

grant(doc, user, drepturi) verifică un PartajareDocument activ pe doc al cărui beneficiar este fie user.client, fie user (operator) — vezi §5.3.

poateCiti(user, doc):
if doc.nivelAcces == PUBLIC: return true
if doc.proprietar == user.client: return true # proprietarul mereu
if doc.nivelAcces == SECRET: # L3 — cel mai strict
return user.hasAuthority(ROLE_SECRET) # rol cu nivelMaxim=SECRET
and grant(doc, user, {CITIRE, CITIRE_SCRIERE}) # grant EXPLICIT pe ACEST doc
if doc.nivelAcces == PERSONAL: # L2
return user.rol in (admin, admin_securizat)
or grant(doc, user, {CITIRE, CITIRE_SCRIERE})
return false
poateScrie(user, doc):
return poateCiti(user, doc)
and (user.rol.poateScrie
or grant(doc, user, {SCRIERE, CITIRE_SCRIERE}))

L3 („secret de stat”) nu are cale „blanket”: nici măcar admin_securizat nu vede un L3 fără un PartajareDocument țintit pe acel document exact — și fiecare astfel de acces scrie EvenimentDocument (ACCES_ACORDAT / VIZUALIZAT). Dacă poateCiti/poateScrie = false, solicitantul (Client sau operator) poate depune o CerereAcces (§6.3), care la aprobare creează PartajareDocument.


stateDiagram-v2
[*] --> ACTIV: upload / generare din template
ACTIV --> ACTIV: vizualizare / modificare / partajare / semnare
ACTIV --> ARHIVAT: expirare (dataExpirarii) sau înlocuire → comprimare
ARHIVAT --> ACTIV: restaurare din arhivă
ACTIV --> STERS: ștergere (trash bin)
ARHIVAT --> STERS: ștergere din arhivă
STERS --> ACTIV: restaurare (< 30 zile)
STERS --> [*]: purjare automată (după 30 zile)
  • Arhivare — documentele expirate sau înlocuite trec în ARHIVAT și se comprimă (worker RabbitMQ); rămân căutabile de operatori.
  • Trash bin — ștergerea marchează STERS + stersLa; un job programat purjează definitiv după 30 de zile; până atunci se pot restaura.
  • Fiecare tranziție scrie o intrare în EvenimentDocument (audit).

sequenceDiagram
actor U as Client/Operator
participant SPA as Angular SPA
participant API as DocumentResource
participant S as StorageService (MinIO)
participant MQ as RabbitMQ
participant W as Worker (ingestie/OCR)
participant ES as Elasticsearch
U->>SPA: alege fișier + nivel acces + securitate
SPA->>API: POST /api/documents (metadate)
API->>S: PUT obiect (binar)
API->>API: generează cod, hash SHA-256, statut=ACTIV
API->>MQ: publică job „ingest {docId}"
API-->>SPA: 201 (document ACTIV)
W->>MQ: consumă job
W->>S: citește binar → extrage text (OCR dacă imagine)
W->>API: salvează textIndexat + etichete
W->>ES: indexează Document
W->>API: EvenimentDocument CREAT
sequenceDiagram
actor U as Utilizator
participant SPA as Angular SPA
participant Q as DocumentQueryService + ACL
participant ES as Elasticsearch
U->>SPA: termen + filtre (an, emitent, cod)
SPA->>Q: GET /api/documents?query=… (sau /api/public/search)
Q->>Q: aplică gardă nivel acces (public → doar L1)
Q->>ES: interogare full-text + fațete
ES-->>Q: rezultate (doar cele permise)
Q-->>SPA: listă + fațete (emitent / an / format)
sequenceDiagram
actor C as Client
participant SPA as Angular SPA
participant API as CerereAccesResource
actor O as Operator (admin)
participant P as PartajareService
C->>SPA: „cere acces" (drept + motivare)
SPA->>API: POST /api/cerere-acces (statut=PENDING)
API-->>O: apare în „Servicii → Cereri"
O->>API: aprobă
API->>P: creează PartajareDocument (drept)
API->>API: EvenimentDocument ACCES_ACORDAT
API-->>C: notificare (acces acordat)
sequenceDiagram
actor U as Client
participant API as ConversieResource
participant MQ as RabbitMQ
participant W as Worker conversie (OCR/format)
participant S as StorageService
U->>API: POST /api/conversii (tip: PHOTO_TO_WORD, sursă)
API->>MQ: job conversie (statut=PENDING)
API-->>U: 202 (în procesare)
W->>MQ: consumă → PROCESSING
W->>S: citește sursa → OCR + reformatare (ML)
W->>S: scrie rezultatul
W->>API: statut=DONE + rezultatUri + rezultatMeta

Aceeași rețetă dual-auth ca interdictii/gnotify: chain OIDC (@Order(1)) pentru UI + jhi_user/JWT ca fallback; realm gdocs pe sso.gstack.esempla.systems, client gdocs-web. Identitatea Keycloak se corelează cu AppUser (rol + client).


GrupExempleAuth
CRUD entități/api/documents, /api/templates, /api/clients, /api/rols, /api/emitents, /api/cerere-acces, /api/conversii, /api/partajare-documentsJWT (rol)
FișierePOST /api/documents (multipart), GET /api/documents/{id}/download (ACL + stream / URL pre-semnat)JWT + ACL
CăutareGET /api/documents?query=…&emitent=…&an=… (fațete)JWT + gardă nivel
PublicGET /api/public/search (doar L1, fără auth)—
Mașină-mașină/api/v1/** (alte sisteme gStack: emit/consumă documente)token de serviciu Keycloak (OAuth2 client-credentials)
Admin/api/admin/* (Configurare, Jurnale, purjare trash, re-index)rol admin

Autentificare /api/v1 (decizie): fiecare sistem consumator are un client Keycloak în realm-ul gdocs și obține un token prin client-credentials; apelează /api/v1 cu Authorization: Bearer …. gdocs îl validează ca resource-server OIDC; scope-urile se mapează pe niveluri de acces (ex. docs:read:public → doar L1; docs:write → încărcare). NU mTLS (diferă de gnotify) — rămâne consistent cu SSO-ul deja folosit de UI.

sequenceDiagram
participant S as Sistem gStack (ex. interdictii)
participant KC as Keycloak (realm gdocs)
participant API as GDocs /api/v1
S->>KC: POST /token (client_credentials, client=interdictii-svc)
KC-->>S: access_token (scopes: docs:read:public …)
S->>API: GET /api/v1/documents?… (Bearer)
API->>API: validează token + mapează scope → nivel acces
API-->>S: documente permise (gardă L1/L2/L3)

Erorile CRUD/admin păstrează JHipster problem+json; /api/v1 poate folosi un format simplu {"message":…} (ca gnotify), scopat pe pachetul v1.


  • GDS / gstyle este sursa de adevăr vizuală (reutilizat pe tot gStack), DAR accentul este cherry red, nu albastru (mockup: accent rose). Definit ca temă GDS (token --gds-accent), la fel ca celelalte accente.
  • Landing în 3 blocuri mari (ca în mockups): Documente, Servicii, Administrare — carduri navigabile către secțiuni.
  • Meniu lateral (sidenav GDS) în 3 grupuri:
    • Documente — Documentele mele · Template-uri · Documente secrete (L3) · Arhivă · Upload
    • Servicii — Transcriere · Cereri · Trash bin
    • Administrare (doar admini) — Utilizatori · Roluri · Configurare · Jurnale · Loguri
  • Mockup-urile de referință: assets/mockups/*.html (versiunile red = accentul cherry).

Din brief §nonfunctional:

  • 5 milioane de utilizatori înregistrați; 100 000 utilizatori concurenți.
  • Replici DB master-slave cu failover (promovare slave dacă master cade).
  • Load balancer + mai mulți workeri (procesare asincronă) + auto-scale (noduri noi la vârf de trafic).
  • Object-store scalabil (MinIO/S3) pentru fișiere; DB ține doar metadate.
  • Elasticsearch pentru căutarea full-text la scară (independent de DB).
  • RabbitMQ decuplează ingestia/OCR/comprimarea de calea de request.
flowchart LR
lb["Load balancer"] --> be1["API #1"]
lb --> be2["API #2"]
lb --> beN["API #N"]
be1 & be2 & beN --> pgm[("PG master")]
pgm -. replicare .-> pgs1[("PG slave")]
pgm -. replicare .-> pgs2[("PG slave")]
be1 & be2 & beN --> es[("ES cluster")]
be1 & be2 & beN --> mq[["RabbitMQ"]]
mq --> w1["Worker OCR"]
mq --> w2["Worker arhivă"]
be1 & be2 & beN --> store[("MinIO/S3")]

Vezi și checklist-ul din db_schema.md §8.

Închise (2026-07-16):

  • ✅ Nivel 3 („secret de stat”) — admin_securizat deschide un L3 doar cu authority ROLE_SECRET ȘI un PartajareDocument explicit pe acel document (beneficiarOperator). Fără cale „blanket”; fiecare acces jurnalizat. Vezi §4.3.
  • ✅ /api/v1 (mașină-mașină) — token de serviciu Keycloak (OAuth2 client-credentials, scope→nivel de acces). NU mTLS. Vezi §7.
  • ✅ JSONB — amânat: @Lob text pentru demo; jsonb = pas de hardening (db_schema.md §7).
  • ✅ Etichete/keywords — jsonb array pe Document; fațete/grupare în Elasticsearch. Fără tabel Eticheta M2M.
  • ✅ Semnătura MSign — entitate SemnaturaDocument (multi-semnatar).

Închise (2026-07-16, runda 2) — detalii în §11:

  • ✅ Identificatori — Client unic pe (tip, identificator) (changelog). cod generat server-side: DOC-<an>-<secvență 6 cifre> (Document), TPL-<an>-<secvență> (Template).
  • ✅ ROLE_SECRET — derivată la login din Rol.nivelMaxim = SECRET (authority-mapper Spring); Rol rămâne unica sursă de adevăr, fără mapare separată în Keycloak.
  • ✅ Purjare trash + comprimare arhivă — hibrid: @Scheduled (detecție zilnică) + workeri RabbitMQ (comprimare/purjare grea).
  • ✅ Object-store — MinIO (dev) → S3-compatibil (prod); descărcare prin backend pentru L2/L3 (ACL), URL pre-semnate doar pentru L1.
  • ✅ Ștergere/retenție — soft-delete statut=STERS; la +30 zile se șterge binarul și se golește storageUri, dar rândul rămâne tombstone cu jurnalul EvenimentDocument păstrat permanent (niciodată hard-delete).

Toate deciziile sunt închise — design gata de generare.


11. Storage, operațiuni programate & retenție

Section titled “11. Storage, operațiuni programate & retenție”

Detalierea deciziilor închise în §10 (runda 2).

  • Client — unicitate pe (tip, identificator) (un IDNP/IDNO poate apărea o singură dată per tip). Constrângere prin changelog Liquibase dedicat (JDL nu suportă unique compus).
  • cod generat de server la creare, needitabil din formular:
    • Document → DOC-<an>-<secvență 6 cifre> (ex. DOC-2026-000481).
    • Template → TPL-<an>-<secvență>.
    • Secvența dintr-o secvență DB per an (sau tabel de contoare); coliziuni evitate la nivel de DB (unique).

Rol (tabelul administrabil) rămâne unica sursă de adevăr. La autentificare, un GrantedAuthoritiesMapper adaugă authority-ul Spring ROLE_SECRET dacă AppUser.rol.nivelMaxim = SECRET (rolul admin_securizat). Nu se configurează o authority separată în Keycloak — evită dublarea sursei de adevăr și menține regulile de acces editabile din blocul „Administrare → Roluri”. ROLE_SECRET este condiția (a) din decizia de acces L3 (§4.3); grantul explicit e condiția (b).

flowchart LR
subgraph Acces
l12["Document L2 / L3"]
l1["Document L1 (public)"]
end
l12 -->|"GET /documents/{id}/download"| be["Backend (verifică ACL)"]
be -->|"stream octeți"| user1["Client / Operator"]
l1 -->|"URL pre-semnat (TTL scurt)"| store[("MinIO / S3")]
store --> user2["Oricine"]
  • Dev/demo: MinIO (compatibil S3). Prod: stocare S3-compatibilă.
  • L2 / L3 — descărcare exclusiv prin backend: se verifică ACL-ul, se scrie EvenimentDocument DESCARCAT, apoi se face stream. Niciun URL direct (ca să nu scape conținut prin linkuri partajabile).
  • L1 public — se pot emite URL-uri pre-semnate cu TTL scurt (descarcă presiunea de pe backend pentru documente publice la scară).
  • Fișierul binar NU trece niciodată prin DB; Document.storageUri = cheia obiectului.

11.4 Operațiuni programate (arhivă & trash)

Section titled “11.4 Operațiuni programate (arhivă & trash)”

Model hibrid — detecția e ieftină și periodică (@Scheduled), munca grea (comprimare / ștergere binar) e delegată workerilor RabbitMQ:

flowchart TB
sched["@Scheduled (zilnic)"] -->|"data_expirarii < azi & statut=ACTIV"| q1["coadă: arhivează {docId}"]
sched -->|"statut=STERS & sters_la < azi-30z"| q2["coadă: purjează {docId}"]
q1 --> w1["Worker arhivă"]
w1 -->|"comprimă binar în store"| store[("MinIO/S3")]
w1 -->|"statut=ARHIVAT, arhivat_la=now"| db[("PostgreSQL")]
w1 --> ev1["EvenimentDocument ARHIVAT"]
q2 --> w2["Worker purjare"]
w2 -->|"șterge binar + storageUri=null"| store
w2 -->|"păstrează rândul (tombstone) + evenimente"| db
w2 --> ev2["EvenimentDocument STERS (definitiv)"]
  • Arhivare — documentele expirate (sau înlocuite) → ARHIVAT + binar comprimat; rămân căutabile de operatori.
  • Trash (30 zile) — ștergerea marchează STERS + sters_la; până la 30 zile se pot restaura (STERS → ACTIV). Sweep-ul zilnic enqueue-ază purjarea celor expirate.
  • Soft-delete peste tot (statut = STERS) — nimic nu dispare instant.
  • Purjarea (după 30 zile) șterge doar binarul din object-store și golește storage_uri; rândul document rămâne ca tombstone (metadate + statut), iar EvenimentDocument se păstrează permanent (forensic / Jurnale).
  • Motiv: EvenimentDocument.document_id este FK required + append-only — un hard-delete al documentului ar rupe/casca jurnalul de audit. Tombstone-ul păstrează integritatea auditului chiar și după ce conținutul a fost eliminat.