Prava plati za firmu, ne za cloveka

permissionsOf(user) scitalo role pres vsechna clenstvi, takze kdo byl
spravce v jedne firme, jednal jako spravce ve vsech, kam patril. Uzivatel
ve dvou firmach je vzacny, dusledek ne.

- firma je povinny argument u permissionsOf, hasPermission i hasAnyPermission.
  Zamerne povinny: kdyby byl nepovinny, prvni volani bez nej by tise vratilo
  vsechna prava. Prekladac tim rovnou nasel vsech ctrnact mist, ktera se ptaji.
- kazde misto se pta za tu spravnou firmu: akce nad ticketem za firmu toho
  ticketu, uprava zaznamu za firmu toho zaznamu, zalozky a helpdesk za firmu,
  kterou ma clovek prepnutou
- 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.
- cache prav ma v klici firmu
- readScope cte z jedne firmy, te prepnute, ne ze vsech. V nastaveni se driv
  michaly typy ticketu a role napric firmami bez ohledu na vyber.

Opraveno u toho: systemove role se pri startu srovnaji s kodem
(syncSystemRoles). Vychozi sada se pouzije jen do prazdneho uloziste, takze
nove pravo v katalogu se k uz bezici instalaci nikdy nedostalo - ticket.create
ani obe prava helpdesku by spravci firmy nenabehla a zalozka Helpdesk by se
neobjevila. Meni se jen prava, jen u nasich roli, nazvu se to netyka.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-26 07:57:01 +02:00
co-authored by Claude Opus 5
parent 2329ccbdc1
commit 2e0b9456a9
11 changed files with 254 additions and 49 deletions
+22 -2
View File
@@ -76,14 +76,34 @@ export function accessFor(user: User, tenantId?: string | null): Access {
tenants: tenants.map((tenant) => ({ id: tenant.id, name: tenant.name })),
defaultTenantId,
// Zustava kvuli klientovi, ale uz se pocita z prava, ne z role.
canAssignOthers: hasPermission(user, 'ticket.assign.others'),
canAssignOthers: hasPermission(user, 'ticket.assign.others', activeTenant),
personId: person?.id ?? null,
permissions: [...permissionsOf(user)].sort(),
// Prava se pocitaji za **vybranou firmu**. Kdo je spravce v jedne firme
// a resitel v druhe, uvidi po prepnuti jen to, co smi tam.
permissions: [...permissionsOf(user, activeTenant)].sort(),
nav: navFor(user, activeTenant),
platformAdmin: user.platformAdmin,
};
}
/**
* Firma, za kterou request jedna.
*
* Jedno misto, kde se to rozhoduje, protoze podle toho se pocitaji prava.
* Vraci `null`, kdyz uzivatel do zadane firmy nepatri nebo zadnou nema -
* volajici pak nema pravo na nic, coz je spravna odpoved.
*/
export function actingTenant(user: User, requested?: string): string | null {
const access = accessFor(user, requested);
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId) return null;
if (!access.tenants.some((tenant) => tenant.id === tenantId)) {
console.warn(`[access] ${user.email}: pokus o firmu ${tenantId} bez clenstvi`);
return null;
}
return tenantId;
}
export interface ResolvedScope {
scope: TicketScope;
/** Firmy, ze kterych se smi vracet data. Prazdne pole = nic. */
+15
View File
@@ -30,6 +30,7 @@ import {
refreshRoles,
roleStore,
setDynamicPermissions,
syncSystemRoles,
systemRoles,
invalidatePermissions,
} from './permissions.js';
@@ -122,6 +123,20 @@ export async function bootstrapData(options: { databaseReady: boolean }): Promis
}
}
/*
* Systemove role srovnat s kodem **pred** naplnenim kesi. Vychozi sada se
* pouzije jen do prazdneho uloziste, takze bez tohohle by nove pravo
* v katalogu k uz bezici instalaci nikdy nedoslo.
*/
try {
await syncSystemRoles();
} catch (err) {
console.error(
'[data] systemove role se nepodarilo srovnat:',
err instanceof Error ? err.message : err,
);
}
await refreshCaches();
console.info(`[data] nactena uloziste (${mode}): ${entities.map((e) => e.store.kind).join(', ')}`);
+82 -12
View File
@@ -235,6 +235,46 @@ export async function refreshRoles(): Promise<void> {
invalidatePermissions();
}
/**
* Srovna ulozene systemove role s tim, jak jsou napsane v kodu.
*
* Vychozi sada se pouzije jen do prazdneho uloziste. Bez tohohle by nove pravo
* v katalogu k existujici instalaci nikdy nedoslo: `role_admin` by zustala
* s vyctem, ktery se ulozil pri prvnim startu, a nova zalozka by se spravci
* firmy neobjevila. Presne to se stalo u prava zalozit ticket a u helpdesku.
*
* Meni se **jen prava**, a jen u roli, ktere jsou nase (`tenantId: null`
* a ID ze `systemRoles`). Nazev ani popis se neprepisuje, ty uz nekdo mohl
* zmenit, a vlastnich roli firmy se to netyka vubec.
*/
export async function syncSystemRoles(): Promise<void> {
const stored = new Map((await roleStore.listAll()).map((role) => [role.id, role]));
let changed = 0;
for (const role of systemRoles()) {
const current = stored.get(role.id);
if (!current || current.tenantId !== null) continue;
const same =
current.permissions.length === role.permissions.length &&
role.permissions.every((permission) => current.permissions.includes(permission));
if (same) continue;
const added = role.permissions.filter((p) => !current.permissions.includes(p));
const removed = current.permissions.filter((p) => !role.permissions.includes(p));
await roleStore.update(role.id, { permissions: role.permissions }, { tenantIds: [], includeGlobal: true });
changed += 1;
console.info(
`[permissions] role ${role.key} srovnana s kodem` +
(added.length > 0 ? `, pribylo: ${added.join(', ')}` : '') +
(removed.length > 0 ? `, ubylo: ${removed.join(', ')}` : ''),
);
}
if (changed > 0) await refreshRoles();
}
export function rolesById(): Map<string, Role> {
return new Map(loadedRoles.map((role) => [role.id, role]));
}
@@ -245,18 +285,26 @@ export function rolesFor(tenantId: string | null): Role[] {
}
/**
* Prava uzivatele.
* Prava uzivatele **v jedne firme**.
*
* Spojeni pres vsechna clenstvi, protoze pravo je bud, nebo neni - rozlisovat
* ho za firmu by znamenalo predavat firmu do kazde kontroly a to je presne to
* misto, kde se na ni zapomene.
* Driv se 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
* i ve vsech ostatnich, kam patri. To neni drobnost, to je diera v pravech.
*
* Spravce platformy ma vsechno. Je to nase pravo, ne zakaznicke.
* Firma je proto **povinny argument**. Zapomenout na ni nejde: kdyby byla
* nepovinna, prvni volani bez ni by tise vratilo vsechna prava.
*
* `tenantId: null` znamena "mimo firmu", tedy zadna zakaznicka prava. Deje se
* to u uctu bez clenstvi; spravce platformy se sem nedostane, ten ma vsechno.
*/
export function permissionsOf(user: User): Set<PermissionKey> {
export function permissionsOf(user: User, tenantId: string | null): Set<PermissionKey> {
// Spravce platformy ma vsechno. Je to nase pravo, ne zakaznicke.
if (user.platformAdmin) return new Set(allPermissions().map((item) => item.key));
if (!tenantId) return new Set();
const cached = effective.get(user.id);
// Klic nese firmu, jinak by se prvni dotaz odpovedel i na druhou.
const cacheKey = `${user.id}:${tenantId}`;
const cached = effective.get(cacheKey);
if (cached && Date.now() - cached.at < CACHE_MS) return cached.permissions;
const byId = rolesById();
@@ -264,6 +312,8 @@ export function permissionsOf(user: User): Set<PermissionKey> {
const result = new Set<PermissionKey>();
for (const membership of user.memberships) {
if (membership.tenantId !== tenantId) continue;
for (const roleRef of membership.roleIds) {
// Odkaz muze byt ID nebo klic systemove role, aby slo zapsat oboji.
const role = byId.get(roleRef) ?? byKey.get(roleRef);
@@ -271,6 +321,18 @@ export function permissionsOf(user: User): Set<PermissionKey> {
console.warn(`[permissions] ${user.email}: clenstvi odkazuje na neznamou roli ${roleRef}`);
continue;
}
/*
* Role patri bud vsem (`tenantId: null`, systemova), nebo jedne firme.
* Cizi firemni role se ignoruje - kdyby platila, staciloby si ji pripsat
* do clenstvi v jine firme a prava by se prenesla.
*/
if (role.tenantId !== null && role.tenantId !== tenantId) {
console.warn(
`[permissions] ${user.email}: role ${role.key} patri firme ${role.tenantId}, ` +
`nepouzije se ve firme ${tenantId}`,
);
continue;
}
for (const permission of role.permissions) {
if (knownPermission(permission)) result.add(permission);
else console.warn(`[permissions] role ${role.key}: nezname pravo ${permission}`);
@@ -278,16 +340,24 @@ export function permissionsOf(user: User): Set<PermissionKey> {
}
}
effective.set(user.id, { at: Date.now(), permissions: result });
effective.set(cacheKey, { at: Date.now(), permissions: result });
return result;
}
export function hasPermission(user: User, permission: PermissionKey): boolean {
return permissionsOf(user).has(permission);
export function hasPermission(
user: User,
permission: PermissionKey,
tenantId: string | null,
): boolean {
return permissionsOf(user, tenantId).has(permission);
}
/** Ma uzivatel aspon jedno z prav? Pro zalozky, kde staci cokoliv z modulu. */
export function hasAnyPermission(user: User, permissions: PermissionKey[]): boolean {
const owned = permissionsOf(user);
export function hasAnyPermission(
user: User,
permissions: PermissionKey[],
tenantId: string | null,
): boolean {
const owned = permissionsOf(user, tenantId);
return permissions.some((permission) => owned.has(permission));
}
+1 -1
View File
@@ -182,7 +182,7 @@ export function navFor(user: User, tenantId: string | null): NavItem[] {
if (module.platform) return user.platformAdmin;
if (!module.required && !enabled.has(module.key)) return false;
if (module.permissions.length === 0) return true;
return hasAnyPermission(user, module.permissions);
return hasAnyPermission(user, module.permissions, tenantId);
})
.map((module) => ({ key: module.key, label: module.label }));
}
+32 -4
View File
@@ -23,7 +23,7 @@
import { randomUUID } from 'node:crypto';
import { Router, type Request, type Response } from 'express';
import type { z } from 'zod';
import { accessFor } from '../data/access.js';
import { accessFor, actingTenant } from '../data/access.js';
import { hasPermission } from '../data/permissions.js';
import { nowIso, type EntityStore, type ListOptions, type TenantEntity } from '../data/store/index.js';
@@ -46,9 +46,33 @@ export function currentTenant(req: Request, res: Response): string | null {
}
/** Rozsah pro cteni: firmy uzivatele plus volitelne platformni zaznamy. */
/**
* Firma, za kterou request jedna. Podle ni se pocitaji prava.
*
* Na rozdil od `currentTenant` neodpovida - vraci jen hodnotu, protoze
* kontrola prava chce sama rict, co se stalo.
*/
export function requestTenant(req: Request): string | null {
return actingTenant(
req.user!,
typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
);
}
/**
* Ze ktere firmy smi request cist.
*
* **Jedna firma, ta prepnuta**, ne vsechny, do kterych clovek patri. Driv to
* byly vsechny a v nastaveni se pak michaly typy ticketu i role napric firmami
* bez ohledu na to, co mel uzivatel vybrane - stejna vada jako u prepinace,
* jen na jinem miste.
*
* `includeGlobal` propousti platformni zaznamy (`tenantId: null`), tedy
* systemove role a podobne. Ty nepatri zadne firme a plati pro vsechny.
*/
export function readScope(req: Request, includeGlobal = true): ListOptions {
const access = accessFor(req.user!);
return { tenantIds: access.tenants.map((tenant) => tenant.id), includeGlobal };
const tenantId = requestTenant(req);
return { tenantIds: tenantId ? [tenantId] : [], includeGlobal };
}
export interface CrudOptions<T extends TenantEntity, C, U> {
@@ -92,7 +116,11 @@ export function crudRouter<T extends TenantEntity, C, U>(options: CrudOptions<T,
});
return false;
}
if (!hasPermission(req.user!, options.writePermission)) {
/*
* Prava se ptaji za firmu **toho zaznamu**, ne za tu, kterou ma clovek
* prave prepnutou. Kdo edituje zaznam jine firmy, musi mit pravo tam.
*/
if (!hasPermission(req.user!, options.writePermission, entity?.tenantId ?? requestTenant(req))) {
console.warn(
`[crud] ${req.user!.email}: chybi pravo ${options.writePermission} u ${options.store.kind}`,
);
+5 -4
View File
@@ -291,7 +291,7 @@ dashboardRouter.get('/intake', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'connector.manage')) {
if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) {
return res.status(403).json({
error: 'forbidden',
message: 'Adresu pro příjem vidí ten, kdo spravuje napojení.',
@@ -326,7 +326,7 @@ dashboardRouter.post('/intake/regenerate', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'connector.manage')) {
if (!hasPermission(req.user!, 'connector.manage', scope.tenantId)) {
return res.status(403).json({
error: 'forbidden',
message: 'Adresu pro příjem mění ten, kdo spravuje napojení.',
@@ -580,7 +580,7 @@ dashboardRouter.post('/tickets', (req, res) => {
const scope = scopeOrDeny(req, res);
if (!scope) return;
if (!hasPermission(req.user!, 'ticket.create')) {
if (!hasPermission(req.user!, 'ticket.create', scope.tenantId)) {
return res.status(403).json({
error: 'forbidden',
message: 'Nemáte právo zakládat tickety.',
@@ -685,7 +685,8 @@ dashboardRouter.post('/tickets/:id/claim', (req, res) => {
if (current !== null && current !== access.personId) {
// Uz to nekdo resi. Prehodit praci nekomu jinemu je jine rozhodnuti.
if (!hasPermission(req.user!, 'ticket.assign.others')) {
// Pravo se pta za firmu ticketu, ne za tu prave prepnutou.
if (!hasPermission(req.user!, 'ticket.assign.others', ticket.tenantId)) {
return res.status(409).json({
error: 'already_assigned',
message: 'Ticket už někdo řeší. Přehodit ho může jen ten, kdo na to má právo.',
+21 -14
View File
@@ -25,7 +25,7 @@
import { Router, type Request, type Response } from 'express';
import { z } from 'zod';
import { accessFor } from '../data/access.js';
import { actingTenant } from '../data/access.js';
import { recordAudit } from '../data/audit.js';
import { hasPermission } from '../data/permissions.js';
import { findPersonByEmail } from '../data/people.js';
@@ -42,7 +42,25 @@ export const helpdeskRouter = Router();
* kdo se pta.
*/
function asking(req: Request, res: Response, permission: string): string | null {
if (!hasPermission(req.user!, permission)) {
/*
* Nejdriv firma, teprve pak pravo. Prava se pocitaji **za firmu**, takze
* bez ni neni ceho se ptat - a kdyz clovek patri do vic firem, rozhoduje
* ta, kterou ma prepnutou.
*/
const tenantId = actingTenant(
req.user!,
typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined,
);
if (!tenantId) {
res.status(403).json({
error: 'no_tenant',
message: 'Účet nepatří do žádné firmy, za kterou by šlo požadavek poslat.',
});
return null;
}
if (!hasPermission(req.user!, permission, tenantId)) {
res.status(403).json({
error: 'forbidden',
message:
@@ -53,17 +71,6 @@ function asking(req: Request, res: Response, permission: string): string | null
return null;
}
const requested = typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined;
const access = accessFor(req.user!, requested);
const tenantId = requested ?? access.defaultTenantId;
if (!tenantId || !access.tenants.some((tenant) => tenant.id === tenantId)) {
res.status(403).json({
error: 'no_tenant',
message: 'Účet nepatří do žádné firmy, za kterou by šlo požadavek poslat.',
});
return null;
}
return tenantId;
}
@@ -109,7 +116,7 @@ helpdeskRouter.get('/', (req, res) => {
tenantId,
/** Komu se posila. Klient podle toho rekne, na koho se pozadavek obraci. */
provider: provider ? { id: provider, name: findTenant(provider)?.name ?? provider } : null,
canCreate: hasPermission(req.user!, 'helpdesk.create'),
canCreate: hasPermission(req.user!, 'helpdesk.create', tenantId),
});
});
+3 -2
View File
@@ -25,7 +25,7 @@ import {
import { hasPermission } from '../data/permissions.js';
import { requireAuth } from '../middleware/auth.js';
import { runSandbox } from '../runtime/sandbox.js';
import { currentTenant } from './crud.js';
import { currentTenant, requestTenant } from './crud.js';
export const tenantScriptRouter = Router();
@@ -36,7 +36,8 @@ const PERMISSION = 'action.manage';
/** Vrati false a odpovi, kdyz volajici na zmenu nema pravo. */
function mayWrite(req: Parameters<typeof requireAuth>[0], res: Parameters<typeof requireAuth>[1]): boolean {
if (hasPermission(req.user!, PERMISSION)) return true;
// Pravo za firmu, kterou ma clovek prepnutou. Skript patri jedne firme.
if (hasPermission(req.user!, PERMISSION, requestTenant(req))) return true;
console.warn(`[skripty] ${req.user!.email}: chybi pravo ${PERMISSION}`);
res.status(403).json({ error: 'forbidden', message: 'K této změně nemáte oprávnění.' });
return false;
+4 -3
View File
@@ -118,7 +118,8 @@ ticketActionsRouter.get('/:id/actions', async (req, res) => {
[ticket.tenantId],
{ typeId: ticket.typeId, tags: ticket.tags },
factsOf(ticket),
(permission) => hasPermission(req.user!, permission),
// Pravo za firmu ticketu: akce patri jemu, ne prepnute firme.
(permission) => hasPermission(req.user!, permission, ticket.tenantId),
);
return res.json({ items: actions.map(publicAction) });
@@ -150,7 +151,7 @@ ticketActionsRouter.post('/:id/actions/:actionId', async (req, res) => {
return res.status(404).json({ error: 'not_found', message: 'Akce neexistuje.' });
}
if (!hasPermission(req.user!, actionPermission(action.id))) {
if (!hasPermission(req.user!, actionPermission(action.id), ticket.tenantId)) {
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
@@ -364,7 +365,7 @@ function builtinAction<T>(options: {
const ticket = getTicket(req.params.id, scope.tenantIds);
if (!ticket) return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' });
if (!hasPermission(req.user!, options.permission)) {
if (!hasPermission(req.user!, options.permission, ticket.tenantId)) {
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,