# 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. ```ts 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 ma navazaneho resitele | 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: ```json { "scopes": ["all", "tenant", "mine"], "tenants": [{ "id": "tnt_automia", "name": "Automia" }], "defaultTenantId": "tnt_automia", "canAssignOthers": true, "personId": "ppl_uhlir", "roleNames": ["Správce"] } ``` 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](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](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.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.ts` | | pozvanka | `user.manage`, role jen z te firmy | `src/routes/invites.ts` | | konektor | `connector.manage` | `src/routes/connectors.ts`, `dashboard.ts` | | automatizace | `automation.edit` za firmu automatizace | `src/routes/dashboard.ts` | | akce nad ticketem | pravo akce za firmu ticketu a strop viditelnosti | `src/routes/ticketActions.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. 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. ## 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: ```text 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.