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:
co-authored by
Claude Opus 5
parent
2329ccbdc1
commit
2e0b9456a9
+22
-2
@@ -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. */
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user