Jak bezpečně a efektivně pracovat s více databázemi pro různé organizace?

A.Venturer
Člen | 3
+
0
-

Dobrý den,

s Nette jsem začal doslova včera odpoledne a chtěl bych se zeptat na vaši best practice.

Moje aplikace řeší správu přihlášek na akce pro několik organizací. Napadlo mě následující řešení:

Základní struktura:

Hlavní databáze „main“, která obsahuje:

  • tabulku users s údaji o uživatelích (včetně hesla)
  • sloupec id_organizace, který identifikuje konkrétní organizaci uživatele.

Po přihlášení potřebuji, aby uživatel mohl pracovat pouze se svojí databází (každá organizace má svou vlastní databázi s několika navzájem propojenými tabulkami).

Tok aplikace:

Přihlášení: Uživatel se přihlásí pomocí údajů z databáze main.
Práce s daty: Po přihlášení by měl být uživatel připojen výhradně ke své databázi (např. databáze s názvem odpovídajícím id_organizace).
Klidně můžete navrhnout zcela jiný, vámi osvědčený postup. Je lepší moje potřeby řešit přes oprávnění a role? Hledám bezpečné a jednoduché řešení, které zvládnu jako mírně pokročilý programátor a začátečník s Nette.

Pokud byste mohli sdílet ukázku kódu nebo odkázat na vhodnou část dokumentace, budu moc rád.

Předem děkuji za vaši pomoc!

Kamil Valenta
Člen | 820
+
0
-

Druhou databázovou konexi můžeš mít jako závislost bez autowiringu. Ale já bych ještě chvíli zůstal u toho, proč musí mít každá organizace svou vlastní databázi? Skutečně to bude tak velká aplikace, že bys narážel na limity velikostí tabulek? Nebo proč?

A.Venturer
Člen | 3
+
0
-

Dobrý den, ahoj,

děkuji za váš dotaz i podnět k zamyšlení. Vaše otázky mě přiměly znovu se zamyslet nad přístupem, který jsem zvažoval.

Omezení velikosti databáze není důvodem mého uvažování. Do nejvíce využívané tabulky bude od každé organizace přidáváno maximálně několik tisíc řádků ročně, takže zde problém nevzniká.

Rozdělení databází pro jednotlivé organizace jsem měl na mysli především z těchto důvodů:

Bezpečnost: Hlavním cílem bylo minimalizovat riziko, že by v případě chyby v kódu mohl uživatel vidět data jiné organizace.
Jednodušší dotazy: Chtěl jsem se vyhnout přidávání podmínek podle organizace do většiny dotazů. Zjistil jsem, že to lze řešit, ale nejsem si jistý, jaký přístup by byl nejvhodnější. Máte, prosím, nějaké doporučení, jak by bylo nejlepší toto řešit?
Budoucí rozšíření: Do budoucna jsem uvažoval o možnosti, že by se jednotlivé organizace mohly přímo připojit ke své databázi. Zvažuji, zda by tato potřeba mohla být efektivněji řešena například přes REST API.

Děkuji za váš čas i zájem. Pokud byste měl další doporučení nebo tipy, rád si je vyslechnu.

Editoval A.Venturer (7. 12. 11:25)

Kamil Valenta
Člen | 820
+
+2
-

A.Venturer napsal(a):

Bezpečnost: Hlavním cílem bylo minimalizovat riziko, že by v případě chyby v kódu mohl uživatel vidět data jiné organizace.

I při oddělených databázích může proplout v kódu chyba, která data z jiné databáze zpřístupní. Navíc bude potřeba „někde“ uchovávat hesla k dílčím databázím, což může přinést ještě větší riziko.

Jednodušší dotazy: Chtěl jsem se vyhnout přidávání podmínek podle organizace do většiny dotazů.

Hodně záleží, jak budou koncipovány modelové vrstvy. Ale celkem ideální je, když nějaká metoda vrací základní selectionu a ostatní metody na ni nabalují. Takže omezení dle organizace bude jen v té jedné základní metodě.

Budoucí rozšíření: Do budoucna jsem uvažoval o možnosti, že by se jednotlivé organizace mohly přímo připojit ke své databázi. Zvažuji, zda by tato potřeba mohla být efektivněji řešena například přes REST API.

Pustit je přímo do databáze bude vždy cesta do pekel. A poměrně přímá. Jakékoliv API je cesta správným směrem.

Další věc k zamyšlení. Proč mají být úvodní loginy v nějaké centrální / main databázi? Proč by neměla mít každá organizace svůj virtual, svou databázi a v ní jen své loginy? Prostě pro N zákazníků udržovat N instancí aplikace+databáze. (to je také velmi častý přístup)

mystik
Člen | 312
+
+2
-

Pripadne mit instanci aplikace pro kazdou organizaci a pak jednu malou centralni login aplikaci, ktera jen podle loginu urci na jakou instanci login preda.

A.Venturer
Člen | 3
+
0
-

Děkuji za vaše cenné postřehy a otázky! Na základě zvažování jsem se rozhodl použít jednu databázi a rozlišovat data pomocí sloupce organization_id v každé tabulce. Připravil jsem dva přístupy, které řeší přístup k datům.

Jaký přístup byste na základě své praxe upřednostnili? Budu také rád za vaše postřehy – například odhalení potenciálních chyb, návrhy na zlepšení nebo usnadnění implementace.

Logika registrace nového účtu:
Při vytvoření nového uživatelského účtu se přiřadí organization_id = NULL.

PŘÍSTUP #1: Inspirace od Kamila Valenty.
V modelu mám základní selection. Další metody využívají tento výběr.

ActivityModel:

class ActivityModel
{
    private Explorer $database;

    public function __construct(Explorer $database)
    {
        $this->database = $database;
    }

    /**
     * Základní výběr aktivit - získá všechny aktivity přiřazené konkrétní organizaci
     */
    public function getActivitiesForOrganization(int $organization_id)
    {
        return $this->database->table('activities')
            ->where('organization_id', $organization_id);
    }

    public function getActivity(int $id, int $organizationId): ?ActiveRow
    {
        return $this->getActivitiesForOrganization($organizationId)
            ->where('id', $id)
            ->fetch();
    }

ActivityPresenter:


    private function getOrganizationIdForUser(): int
    {
        $userIdentity = $this->getUser()->getIdentity();
        if ($userIdentity === null || $userIdentity->organization_id === null) {
            throw new \Exception('Organizace není dostupná nebo uživatel není přihlášen.');
        }
        return $userIdentity->organization_id;
    }

    public function renderShow(int $id): void
    {
        $organizationId = $this->getOrganizationIdForUser();
        $activity = $this->activityModel->getActivity($id, $organizationId);
        $this->template->activity = $activity;
    }

PŘÍSTUP #2: Jednoduchý ACL.

RegistrationPresenter

    public function renderDefault(): void
    {
        $organizationId = $this->accessControl->getOrganizationId();
        if ($organizationId === null) {
            $this->error('Nemáte přístup k žádné organizaci.');
        }
        $registrations = $this->registrationModel->getRegistrationForOrganization($organizationId);
        $this->template->registrations = $registrations;
    }

    public function renderShow(int $id): void
    {
        $registration = $this->registrationModel->getRegistration($id);

        if (!$registration) {
            $this->error('Registrace nebyla nalezena.');
        }

        $registrationOrganizationId = $registration->organization_id;
        if (!$this->accessControl->isAllowed('view', $registrationOrganizationId)) {
            $this->error('Nemáte oprávnění zobrazit tuto registraci.');
        }

        $this->template->registration = $registration;
    }

RegistrationModel

    public function getRegistrationForOrganization(int $organizationId): array
    {
        return $this->database->table('registrations')
            ->where('organization_id', $organizationId)
            ->fetchAll();
    }

    public function getRegistration(int $id): ?ActiveRow
    {
        return $this->database->table('registrations')
            ->where('id', $id)
            ->fetch();
    }

AccessControl

    public function getOrganizationId(): ?int
    {
        $identity = $this->user->getIdentity();
        return $identity ? $identity->organization_id : null;
    }

    public function isAllowed(string $action, int $organizationId): bool
    {
        if (!$this->user->isLoggedIn()) {
            return false;
        }

        $userOrganizationId = $this->getOrganizationId();
        if ($userOrganizationId === null || $userOrganizationId !== $organizationId) {
            return false;
        }

        return true;
    }

Pokud máte návrh na zcela jiné a lepší řešení, moc rád si ho poslechnu! Všem ještě jednou děkuji.

mystik
Člen | 312
+
+1
-

Ja bych zvazil jestli nemit nejakou sluzbu (cca jako to AccessControl), ktera by dezela informaci o organizaci kam uzivatel spada, a tu injectovat do modelu/service ktery/a by si pak info o vybrane organizaci brala sama odtud pokud neni explicitne zadano jinak. Mohlo by to usetrit spoustu duplicithiho kodu v presenterech/komponentach kde bys vsude mel zavislost na AccessControl a musel si to id organizace vytahovat a predavat do modelu.

Kamil Valenta
Člen | 820
+
0
-

A.Venturer napsal(a):

ActivityModel:
`php
class ActivityModel
{
private Explorer $database;

public function __construct(Explorer $database)
{
$this->database = $database;
 }

/**
* Základní výběr aktivit – získá všechny aktivity přiřazené konkrétní organizaci
*/
public function getActivitiesForOrganization(int $organization_id)
{
return $this->database->table(‚activities‘)
->where(‚organization_id‘, $organization_id);
 }

public function getActivity(int $id, int $organizationId): ?ActiveRow
{
return $this->getActivitiesForOrganization($organizationId)
->where(‚id‘, $id)
->fetch();
 }

Já bych ID organizace držel v identitě uživatele. ActivityModel si o „user“ požádá v závislostech.

     public function getActivitiesForOrganization()
     {
         return $this->database->table('activities')
             ->where('organization_id', $this->user->getIdentity()->getData()['organization_id']);
     }

     public function getActivity(int $id): ?ActiveRow
     {
         return $this->getActivitiesForOrganization()
             ->where('id', $id)
             ->fetch();
     }
m.brecher
Generous Backer | 873
+
0
-

Ahoj,

Jednou z možností, jak jednoduše zajistit, aby všechny modelové třídy měly k dispozici id organizace (dále $unitId) je předat tuto funkci abstraktnímu předku modelových tříd. Abstraktní model by získal id přihlášeného uživatele, použil ho na dohledání související $unitId a $unitIds použil ve veřejných metodách pro získání tabulkek filtrovaných/nefiltrovaných podle $unitId.


abstract class AbstractModel implements Injectable
{
    protected const string Table = '';

    protected const string UnitKey = 'unit_id';

    private const string UserTable = 'user';

    protected readonly ?int $unitId;

    protected readonly Explorer $explorer;

    public function injectServices(Explorer $explorer, User $user)
    {
        $this->explorer = $explorer;
        $this->unitId = $this->getUnitId($user);
    }

    private function getUnitId($user): ?int
    {
        return $this->explorer->table(self::UserTable)->get($user->id)->{static::UnitKey};
    }

    public function getTable(?string $table = null): Selection
    {
        return $this->explorer->table($table ?? static::Table);
    }

    public function getTableOnUnit(?string $table = null): Selection
    {
        return $this->getTable($table)->where('unit_id', $this->unitId);
    }
}

AbstractModel získá dependency pomocí metody inject, aby konstruktor zůstal volný pro použití ve ve finálních modelových třídách. Nette kromě presenterů automaticky režim Inject neaktivuje, je potřeba to zajistit ručně. Ideální je využít interface Injectable a decorator v konfiguraci:


interface Injectable
{}

sevices.neon:


decorator:
	Bite\Components\Injectable:
		inject: true			# aktivace režimu Inject

Ukázka finální modelové třídy:


final class ActivityModel extends AbstractModel
{
    public const string Table = 'activity';

    public function findAll(): Selection
    {
        return $this->getTableOnUnit();
    }

    public function findByCategory(string $categoryId): Selection
    {
        return $this->getTableOnUnit()->where('category_id', $categoryId);
    }
}

Použití v presenteru, ActivityModel dodává data vždy bezpečně filtrovaná podle aktuálního $unitId:


class ActivityPresenter extends BasePresenter
{
    public function __construct(private readonly ActivityModel $activityModel)
    {}

    public function actionDefault(string $categoryId): void
    {
        $articles = $this->activityModel->findAll();					// aktivity organizace
        $articles = $this->activityModel->findByCategory($categoryId);	// aktivity organizace v kategorii
    }
}

Ve finálních modelových třídách si jenom dáš pozor, aby Jsi zvolil správnou variantu ze dvou metod pro získání tabulky:

  • getTable() záznamy všech organizací
  • getTableOnUnit() záznam aktuální organizace

Samostatná služba pro předání $unitId ani není potřeba, protože při správném návrhu je $unitId potřeba pouze v modelových třídách. Kdyby Jsi přesto potřeboval mít službu pro $unitId samostatně, není to žádný problém:


final class UnitProvider
{
    public const string UserTable = 'user';

    public const string UnitKey = 'unit_id';

    private readonly ?int $unitId;

    public function __construct(
        private readonly Explorer $explorer,
        private readonly User $user,
    )
    {}

    public function getId(): ?int
    {
        return $this->unitId ?? $this->unitId = $this->explorer->table(self::UserTable)->get($this->user->id)->{self::UnitKey};
    }
}

Do třídy AbstractModel předáš místo User službu UnitProvider

Pozor!

Všeobecně nejsou dobré zkušenosti s výstavbou Modelu cestou hierarchie modelových tříd založené na dědění. Doporučuje se stavět Model pomocí kompozice – ideálně pomocí di. V tomto konkrétním případě je ale distribuce $unitId do všech/většiny modelových tříd pomocí dědění ideální koncept. Problémy vznikají, pokud se do abstraktních modelových tříd umístí specifická business logika. Při změně business logiky je pak peklo provádět složité úpravy v hierarchii dědění.

Editoval m.brecher (11. 12. 20:09)