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
+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,