Files
csbot-prototype/documentation/07-firmy-a-prava.md
T
JiriUhlir c25e826766 Poptavka z webu je ticket, prilohy, udaje provozovatele, kolacovy graf
Provozovatel portalu: firma s priznakem portalOperator (jen jedna, zapnuti
odebere ostatnim) a novymi poli contactEmail, contactPhone, website vedle
ico, dic, adresy a pravni formy. Verejny GET /api/public/brand vraci jeji
udaje a web je bere pres useBrand() na kontaktu, v paticce, O nas,
prihlaseni i v titulku; brand.ts je jen zaloha.

Poptavka z webu zaklada u provozovatele ticket kanalu form: predmet
"Poptavka: tema", telo JSON s poli formulare, tag Poptavka plus tema,
zakaznik z formulare, poznamka v logu. Bez provozovatele se jen zaloguje.

Prilohy ticketu: formular az 3 soubory po 5 MB, ticket az 10; nahrani,
seznam, stazeni a smazani (pravo ticket.comment, strop viditelnosti,
poznamky v logu, audit). Soubor jde v JSON jako Base64 a lezi v beznem
ulozisti, bez nove zavislosti; strop tela jen na techto cestach.

Vlastni widget s kreslenim Graf umi i pocet ticketu se seskupenim jako
kolac (PieChart.tsx, ciste SVG, osm barev z tokenu, zbytek jako ostatni).

OpenAPI rozdelene na mensi soubory (102 cest, 28 schemat overeno shodnych),
28 novych testu (135 celkem), dokumentace aktualizovana.
2026-09-09 19:35:00 +02:00

16 KiB

07 - Firmy a prava

Dve ruzne "firmy"

Snadno se pletou, proto hned na zacatku:

  • Tenant je firma, ktera portal pouziva. Ma v nem svuj tym, svoje tickety a svoje automatizace. Je to hranice viditelnosti.
  • ticket.customer je zakaznik toho tenanta, tedy kdo pozadavek poslal. S pravy nema nic spolecneho.

Tenanti jsou v src/data/tenants.ts.

Uzivatel muze byt ve vic firmach

Proto memberships, ne jedno tenantId. Typicky externista, ktery dela servicedesk dvema klientum a v kazdem ma jinou roli.

interface User {
  id: string;
  email: string;
  name: string;
  platformAdmin: boolean;      // vidi napric vsemi firmami
  memberships: Membership[];   // { tenantId, role }
}

type TenantRole = 'admin' | 'agent';

Role je vzdy az uvnitr firmy. Pristup napric firmami je zvlast jako platformAdmin - to je nase pravo, ne zakaznicke. Kdyby byla role jen jedna globalni, nesla by tahle situace vubec zapsat.

Tri pohledy na tickety

Pohled Co ukazuje Kdo smi
all napric vsemi firmami jen platformAdmin
tenant cela jedna firma kdokoliv, kdo do ni patri
mine jen tickety prihlaseneho kdo je clenem firmy

Posilaji se jako query: ?scope=tenant&tenantId=tnt_automia.

U tenant a mine je potreba vedet kterou firmu, protoze uzivatel jich muze mit vic. Bez tenantId se pouzije prvni.

Prepinac firmy je nad celym portalem

Vybrana firma je jedna hodnota pro cely dashboard (web/src/lib/tenant.ts), ne stav jedne stranky. Prepinac je v sidebaru nad navigaci, tedy nad vsim, co se pod nim kresli, a apiFetch doplnuje tenantId do kazdeho dotazu na /api/dashboard.

Doplnuje se to v API vrstve schvalne, ne na strankach. Kdyz si to mela pridavat kazda stranka sama, vetsina na to zapomnela a server sahnul po vychozi firme - uzivatel se prepnul na LogiTrans a v Lidech koukal na lidi Automie. To neni nepohodli, to jsou cizi data pod hlavickou jine firmy.

Vyjimky, kdy se nedoplnuje:

Kdy Proc
Cesta uz tenantId nese proklik z widgetu na konkretni firmu ma prednost
scope=all pohled pres vsechny firmy, jedna firma tam nic neznamena
Neni vybrano server pouzije vychozi firmu uctu
Cesta mimo /api/dashboard prihlaseni a verejny web s firmou nepracuji

Prepnuti prekresli obsah cely. Obsah dashboardu ma key podle vybrane firmy, takze jina firma znamena novy strom komponent.

Bez toho to nestacilo: useApiQuery se sice zeptat umi, ale komponenty, ktere volaji apiFetch primo - EntityAdmin, a tim cele Nastaveni a zalozky Resitele a Skupiny - o zmene nevi a zustala by na nich data predchozi firmy. Doplnovat zavislost do kazde z nich je totez zapomenuti, jen o patro niz.

Ztrata rozepsaneho stavu je pritom spravne: filtr na cloveka z firmy A nema ve firme B co znamenat.

Volba prezije obnoveni stranky (localStorage). Kdyz ulozena firma uz mezi access.tenants neni - clovek z ni odesel, prihlasil se nekdo jiny na tomtez pocitaci - spadne se na vychozi firmu uctu, aby kazdy dotaz nekoncil na 403.

Prava plati za firmu, ne za cloveka

permissionsOf(user, tenantId) pocita prava v jedne firme. Firma je povinny argument: kdyby byla nepovinna, prvni volani bez ni by tise vratilo vsechna prava, a presne takova chyba se nehleda.

Driv se prava scitala pres vsechna clenstvi. Znelo to rozumne - pravo je bud, nebo neni - ale znamenalo to, ze kdo je spravce v jedne firme, jednal jako spravce ve vsech, kam patri. Uzivatel ve dvou firmach je vzacny, dusledek ne.

Kde se pta Ktera firma rozhoduje
Akce nad ticketem firma toho ticketu
Uprava zaznamu v nastaveni firma toho zaznamu
Zalozeni ticketu, helpdesk firma, kterou ma clovek prepnutou
Zalozky v navigaci firma, kterou ma clovek prepnutou

Rozdil mezi prvnimi dvema a zbytkem je zamer: kdo edituje zaznam jine firmy, musi mit pravo tam, ne tam, kde je zrovna prepnuty.

tenantId: null znamena "mimo firmu", tedy zadna zakaznicka prava. Deje se to u uctu bez clenstvi. Spravce platformy ma vzdycky vsechno - to je nase pravo, ne zakaznicke.

Cizi firemni role se ignoruje, i kdyby na ni clenstvi odkazovalo: jinak by stacilo pripsat si ji do clenstvi v jine firme a prava by se prenesla.

Systemove role se srovnavaji s kodem pri startu

Vychozi sada se pouzije jen do prazdneho uloziste. Nove pravo v katalogu by se proto k uz bezici instalaci nikdy nedostalo: role_admin by zustala s vyctem ulozenym pri prvnim startu a nova zalozka by se spravci firmy neobjevila.

syncSystemRoles() proto pri startu srovna prava systemovych roli s tim, jak jsou napsane v kodu. Meni jen prava, jen u roli s tenantId: null a jen u tech, ktere jsou v systemRoles(). Nazvu se to netyka (ten uz nekdo mohl zmenit) a vlastnich roli firmy uz vubec.

Server je autorita

Vsechno rozhoduje src/data/access.ts, jedno misto pro cely portal. Kdyby se to rozlezlo po routach, driv nebo pozdeji vznikne endpoint, ktery filtr zapomene.

GET /api/dashboard/access vraci, co uzivatel smi:

{
  "scopes": ["all", "tenant", "mine"],
  "tenants": [{ "id": "tnt_automia", "name": "Automia" }],
  "defaultTenantId": "tnt_automia",
  "canAssignOthers": true,
  "personId": "usr_1",
  "roleNames": ["Správce"]
}

personId je ID uctu: resitel je clenstvi uctu ve firme, ne vlastni zaznam (viz 06-tickety.md). null znamena, ze prihlaseny ve zvolene firme clenstvi nema - typicky spravce platformy - a pohled mine pak v seznamu neni.

Pristup se pocita jednou na request (attachAccess v src/middleware/tenant.ts, vysledek v req.access) a routy si z nej berou firmu pres tenantOrDeny. Kazda route, ktera si to pocitala sama, to delala o neco jinak a jedna z nich spatne.

Stream udalosti tutez informaci pouziva k filtru: uzivatel dostane jen udalosti svych firem, viz 04-api.md.

Klient podle toho kresli prepinac. Nesmi si to dovozovat sam - jinak by se prava pocitala na dvou mistech a jednou se rozejdou.

Nikdy tise nezuzujeme

Pozadavek na pohled nebo firmu, na kterou uzivatel nema pravo, vraci chybu, ne potichu zuzeny vysledek:

Situace Odpoved
pohled bez opravneni 403
firma, do ktere nepatri 404
ucet bez firmy 403
prirazeni ostatnim bez prava 403

Duvod: kdyby se pozadavek na cizi firmu jen prepnul na vlastni, uzivatel by koukal na cizi cisla v domneni, ze jsou spravna. To je horsi nez chyba.

Filtr v ulozisti je povinny

listTickets, listPeople a listAutomations maji tenantIds jako povinny argument, ne volitelny. Zapomenuty filtr tak neznamena "vse", ale nezkompiluje se.

Zapis (assignTicket, updateTicketStatus, addComment, updateAutomation, deleteAutomation) bere tenantIds taky. Cizi zaznam se chova jako neexistujici, tedy 404, ne 403 - z odpovedi nemá jit poznat, ze takove ID vubec existuje.

Vyjimka je verejny webhook. Ten se autorizuje tokenem v adrese, ne prihlasenim, takze si automatizaci najde pres vsechny firmy.

Jedina vyjimka: helpdesk

Ticket patri jedne firme a to plati dal. U pozadavku z helpdesku ale figuruji dve: vlastnikem je ta, ktera ho resi, a v helpdeskSourceId je ta, ktera ho poslala. Zadavatel se k nemu dostane jen pres helpdesk a jen ke svym pozadavkum; bezny seznam ticketu zustava vlastnikovi.

Neni to obchazeni hranice, je to druha cesta dovnitr s vlastnim scopem. Popis je v 18-ticketovaci-system.md.

Prirazeni jen v ramci firmy

assignTicket odmitne resitele z jine firmy. Jinak by ticket zmizel z prehledu sve firmy a objevil se nekomu, kdo do ni nepatri.

Prehazovat praci mezi lidmi smi jen admin. agent si smi vzit ticket na sebe, ale nemuze ho poslat kolegovi - to hlida canAssignOthers.

Demo ucty

Heslo je u vsech demo1234.

E-mail Kdo je
admin@automia.cz spravce platformy, vidi vsechny tri firmy
karel.vomacka@automia.cz agent v Automii, admin u Nordisu - dve firmy
martin.kriz@automia.cz bezny resitel jedne firmy

Druhy ucet je ten zajimavy: ukazuje prepinac firem i to, ze prava se lisi podle toho, ktera firma je prave zvolena.

Kdo koho zaklada

Rozhodnuti z revize v zari 2026: firmy zaklada spravce platformy, lidi ve firme spravuje spravce firmy. Je to hranice mezi nasim pravem a zakaznickym a drzi se vsude, kde se neco zaklada:

Co Kdo smi Kde se to kontroluje
firma jen spravce platformy (platformOnly u CRUD firem) src/routes/settings/tenants.ts
firma z registru ARES jen spravce platformy src/routes/ares.ts
uzivatel spravce platformy, nebo user.manage jen ve sve firme src/routes/settings/users.ts
resitel (clen firmy, Lide) people.manage jen ve sve firme, zaklada ucet s clenstvim src/routes/settings/people.ts
pozvanka user.manage, role jen z te firmy src/routes/invites.ts
konektor connector.manage src/routes/connectors.ts, dashboard/misc.ts
automatizace automation.edit za firmu automatizace src/routes/dashboard/automations.ts
stav incidentu incident.manage za firmu incidentu, platformni jen spravce platformy src/routes/dashboard/incidents.ts
akce nad ticketem pravo akce za firmu ticketu a strop viditelnosti src/routes/ticketActions.ts
priloha ticketu ticket.comment za firmu ticketu a strop viditelnosti src/routes/dashboard/attachments.ts
provozovatel portalu jen spravce platformy, prave jedna firma src/routes/settings/tenants.ts

Spravce firmy s user.manage ma jen svou firmu: nenastavi platformAdmin, neprida cizi clenstvi, nesahne na spravce platformy a nesmaze cloveka, ktery je i v jine firme. Posledni bod neni formalita - ucet ve dvou firmach neni jen jeho a smazat ho znamena vzit pristup i te druhe.

Driv u vetsiny rout stacilo clenstvi ve firme. Clen s roli viewer tak mohl zalozit konektor nebo smazat automatizaci. Prava v katalogu byla, jen se na ne routy neptaly.

Firma z registru ARES

Zalozit firmu rucne znamena opsat nazev, IC, DIC a adresu a pak zvlast zakladat ucty lidem, kteri ji povedou. Proto Nastaveni, firma z ARES: spravce platformy zada IC nebo nazev, vybere firmu a z verejneho rejstriku dostane soucasne cleny statutarniho organu a prokuru. Vybrani dostanou ucet s roli role_admin v nove firme a nahodnym heslem. Resitel je clenstvi uctu, ne vlastni zaznam (viz 06-tickety.md), takze jsou v Lidech hned a jde jim prihodit ticket; funkce z rejstriku je popisek clenstvi (role), kapacita je vychozich 8.

Rejstrik e-maily nezna. Kazda osoba proto dostane zastupnou adresu IC-poradi@placeholder.cz, pokud spravce nezada skutecnou. Zastupna adresa se musi nahradit - s ni se clovek neprihlasi a nedostane pozvanku. Portal ji pozna (isPlaceholderEmail), aby slo upozornit.

Firma nese ico, dic, address a legalForm; IC je unikatni a CRUD firem to hlida, takze tataz firma nevznikne dvakrat. Hledani v ARES u uz zalozene firmy vrati existingTenantId misto tlacitka Zalozit.

Od te chvile je to na spravci firmy: skupiny, vedouci, clenove, pozvanky. Spravce platformy do firmy nesaha, pokud nemusi.

Provozovatel portalu

Jedna z firem je provozovatel portalu (Tenant.portalOperator). Je to ta, ktera portal provozuje pro ostatni: chodi ji poptavky z verejneho kontaktniho formulare jako tickety (kanal form, viz 06-tickety.md) a verejny web z ni bere kontaktni a fakturacni udaje (GET /api/public/brand, viz 22-znacka-a-design.md).

Nastavuje ho jen spravce platformy v Nastaveni, Firmy - je to totez rozhodnuti jako zalozeni firmy, nase pravo, ne zakaznicke. Provozovatel je prave jeden: kdyz priznak dostane dalsi firma, ostatnim se sunda (clearOtherOperators v src/data/tenants.ts, volane z afterWrite CRUD firem). Dve firmy s poptavkami by znamenaly, ze se ticket zalozi jen jedne a nikdo nevi ktere. Zmenene firmy se ohlasi do streamu jako tenant.updated, aby si portal opravil sklad ciselniku.

Vypnuta firma provozovatelem neni, i kdyz priznak ma (operatorTenant). Bez provozovatele se poptavka jen zaloguje a web ukaze statickou zalohu.

K tomu firma nese kontaktni udaje pro web: contactEmail, contactPhone a website. Vyplnuji se u provozovatele; u ostatnich firem nic neznamenaji, ale schema je nezakazuje. Prazdny retezec z formulare se uklada jako null (emptyToNull v src/routes/settings/tenants.ts).

Co chybi

Chybi Poznamka
Audit pristupu odepreni se jen loguje, nikde se neuklada
Nahrada zastupnych adres portal je pozna, ale nenuti spravce je vymenit

Strop viditelnosti

Pohled je co chci videt, strop je co vubec smim videt. Driv existoval jen pohled: scope=tenant dostal kazdy, kdo do firmy patril, a mine byl dobrovolny filtr.

Strop pocita visibilityFor v src/data/access.ts a nese ho ResolvedScope, protoze tudy prochazi kazdy dotaz na tickety:

spravce platformy nebo Membership.seesAllTenant  ->  cela firma
jinak                                            ->  moje tickety
                                                   + vse ze sekci, kde mam
                                                     u clenstvi zaskrtnuto
                                                   + fronta techhle sekci

Priznak visi na clenstvi, ne na cloveku a ne na skupine. Clovek muze byt v peti sekcich a jen ve dvou z nich videt vsechno - to role rict neumi, ta je jedna na celou firmu.

seesAllTenant === undefined jsou zaznamy ulozene driv. Rozhoduje u nich pravo ticket.assign.others: kdo smel prehazovat cizi praci, uz stejne cely provoz videl.

Vynucuje se v ulozisti, ne v routach

Strop je povinna soucast TicketFilter, stejne jako tenantIds. Nepovinny filtr na prava je filtr, ktery jednou nekde chybi. getWorkload a getAgentStats proto uz nesahaji do pole ticketu primo, jdou pres listTickets, a getTicket ho kontroluje i u jednoho ticketu - bez toho by stacilo znat ID.

Detail a prevzeti pocitaji strop za firmu ticketu, ne za prave prepnutou: odkaz z pohledu "vse" muze vest do jine firmy uzivatele.

Helpdesk

Tataz hierarchie, jen "moje" znamena neco jineho: zadavatel pozadavek nikdy nema prirazeny. Ticket proto nese createdById a plati, ze kdo vidi celou firmu, vidi vsechny jeji pozadavky, ostatni jen ty svoje.

Nezarazene vidi kazdy

Ticket bez resitele je ve stropu vzdycky, at uz ma skupinu nebo ne.

Prvni verze ho schovavala a byla to diera v provozu: prichozi ticket, ktery jeste nikdo nesmeroval, nepatri do zadne sekce, takze by ho nevidel nikdo krome vedeni - a nikdo by si ho nevzal. Fronta je spolecna, prave proto je to fronta.

Jakmile si ho nekdo vezme, plati strop jako u kazdeho jineho ticketu.