{"id":479210,"date":"2026-05-10T10:18:46","date_gmt":"2026-05-10T10:18:46","guid":{"rendered":"https:\/\/savepearlharbor.com\/?p=479210"},"modified":"-0001-11-30T00:00:00","modified_gmt":"-0001-11-29T21:00:00","slug":"","status":"publish","type":"post","link":"https:\/\/savepearlharbor.com\/?p=479210","title":{"rendered":"\u0422\u0440\u0438 \u0430\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u043d\u044b\u0445 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u0434\u043b\u044f multi-tenant B2B SaaS, \u043e \u043a\u043e\u0442\u043e\u0440\u044b\u0445 \u044f \u043f\u043e\u0436\u0430\u043b\u0435\u043b, \u0447\u0442\u043e \u043d\u0435 \u0443\u0437\u043d\u0430\u043b \u0440\u0430\u043d\u044c\u0448\u0435"},"content":{"rendered":"<div xmlns=\"http:\/\/www.w3.org\/1999\/xhtml\">\n<p>\u0421\u0430\u043c\u0430\u044f \u0434\u043e\u0440\u043e\u0433\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043c\u043e\u0435\u0433\u043e B2B SaaS \u0438\u043c\u0435\u043b\u0430 \u0440\u043e\u0432\u043d\u043e \u043e\u0434\u043d\u0443 \u0441\u0442\u0440\u043e\u0447\u043a\u0443<\/p>\n<pre><code class=\"python\">```python  # app\/config.py  TENANT_ID = \"tenant-1\"  ```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:87px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u041a\u043e\u0433\u0434\u0430 \u0443 \u043c\u0435\u043d\u044f \u0431\u044b\u043b \u043e\u0434\u0438\u043d \u0442\u0435\u043d\u0430\u043d\u0442, \u0432\u0441\u0451 \u0440\u0430\u0431\u043e\u0442\u0430\u043b\u043e \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e. \u041d\u0430 \u0432\u0442\u043e\u0440\u043e\u043c \u2014 \u043f\u043e\u043b\u043e\u0432\u0438\u043d\u0430 \u0430\u0434\u043c\u0438\u043d-\u0441\u0443\u0449\u043d\u043e\u0441\u0442\u0435\u0439 (\u0432\u0440\u0430\u0447\u0438, \u0443\u0441\u043b\u0443\u0433\u0438, \u043f\u0440\u0430\u0439\u0441-\u043b\u0438\u0441\u0442\u044b) \u043d\u0430\u0447\u0430\u043b\u0430 \u043f\u0440\u043e\u043f\u0430\u0434\u0430\u0442\u044c \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043a\u043b\u0438\u0435\u043d\u0442\u0430. \u041d\u0435 \u00ab\u043d\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c\u0441\u044f\u00bb \u2014 \u0430 \u043f\u043e\u044f\u0432\u043b\u044f\u0442\u044c\u0441\u044f \u0432 \u0411\u0414 \u0441 \u0447\u0443\u0436\u0438\u043c <code>tenant_id<\/code>. \u042f \u043f\u043e\u043b\u0442\u043e\u0440\u0430 \u0434\u043d\u044f \u0441\u043c\u043e\u0442\u0440\u0435\u043b \u043d\u0430 \u044d\u0442\u0443 \u043c\u0438\u0441\u0442\u0438\u043a\u0443, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043f\u043e\u043d\u044f\u043b: 30 endpoint\u2019\u043e\u0432 \u0431\u0435\u0440\u0443\u0442 <code>tenant_id<\/code> \u0438\u0437 closure \u0438\u0437 <code>config<\/code>, \u0430 \u043d\u0435 \u0438\u0437 <code>user.tenant_id<\/code>. \u041e\u0447\u0435\u0432\u0438\u0434\u043d\u043e \u0432 \u0440\u0435\u0442\u0440\u043e\u0441\u043f\u0435\u043a\u0442\u0438\u0432\u0435. \u0421\u043e\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e \u043d\u0435\u0432\u0438\u0434\u0438\u043c\u043e \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043f\u0438\u043b\u043e\u0442\u0430.<\/p>\n<p>\u042d\u0442\u043e\u0442 \u0440\u0430\u0437\u0431\u043e\u0440 \u2014 \u043f\u0440\u043e \u0442\u0440\u0438 \u0430\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u043d\u044b\u0445 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u0434\u043b\u044f multi-tenant SaaS \u0432 \u0440\u0435\u0433\u0443\u043b\u0438\u0440\u0443\u0435\u043c\u043e\u0439 \u043e\u0442\u0440\u0430\u0441\u043b\u0438, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u044e\u0442\u0441\u044f \u0432 \u043f\u0435\u0440\u0432\u0443\u044e \u043d\u0435\u0434\u0435\u043b\u044e \u0438 \u043f\u043e\u0442\u043e\u043c \u0433\u043e\u0434\u0430\u043c\u0438 \u043b\u0438\u0431\u043e \u044d\u043a\u043e\u043d\u043e\u043c\u044f\u0442 \u043c\u0435\u0441\u044f\u0446\u044b \u0440\u0435\u0444\u0430\u043a\u0442\u043e\u0440\u0438\u043d\u0433\u0430, \u043b\u0438\u0431\u043e \u0442\u0438\u0445\u043e \u043a\u043e\u043f\u044f\u0442 \u0442\u0435\u0445\u0434\u043e\u043b\u0433. \u0421\u0442\u0435\u043a: Python 3.11 \/ FastAPI \/ SQLAlchemy 2.x \/ PostgreSQL 16. \u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442: B2B SaaS \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043c\u0435\u0434\u0438\u0446\u0438\u043d\u0441\u043a\u0438\u0445 \u043a\u043b\u0438\u043d\u0438\u043a, 152-\u0424\u0417, real-time scheduling.<\/p>\n<p><strong>TL;DR<\/strong><\/p>\n<p>&#8212; <strong>Multi-tenancy \u0447\u0435\u0440\u0435\u0437 <\/strong><code><strong>tenant_id<\/strong><\/code><strong> column + helper <\/strong><code><strong>scoped_select<\/strong><\/code><strong> \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043a\u043e\u043c\u043c\u0438\u0442\u0430<\/strong> \u2014 \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u0442\u0435\u043d\u0430\u043d\u0442 \u043e\u0434\u0438\u043d. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u0432\u043d\u0443\u0442\u0440\u0438.<\/p>\n<p>&#8212; <strong>\u0417\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 double-booking \u0447\u0435\u0440\u0435\u0437 PostgreSQL <\/strong><code><strong>EXCLUDE USING gist + tsrange<\/strong><\/code> \u2014 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0435 \u0446\u0438\u0444\u0440\u044b: 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u043d\u0430 \u043e\u0434\u0438\u043d \u0441\u043b\u043e\u0442 \u2192 1\u00d7200, 11\u00d7409, \u0431\u0435\u0437 application-level lock&#8217;\u043e\u0432.<\/p>\n<p>&#8212; <strong>152-\u0424\u0417 \u2014 \u044d\u0442\u043e \u043f\u043e\u043b\u044f \u0438 helpers<\/strong>: <code>data_category<\/code> enum \u0432 <code>audit_logs<\/code> \u0441 \u0430\u0432\u0442\u043e\u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c, \u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u0430\u044f \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f \u0441 PBKDF2 \u043d\u0430 argon2id \u0431\u0435\u0437 forced reset, <code>patient_consents<\/code> \u0441\u043e scoped \u0432\u0435\u0440\u0441\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c.<\/p>\n<p>&#8212; <strong>\u041e\u0434\u0438\u043d \u0431\u0430\u0433 \u0441 \u0437\u0430\u0445\u0430\u0440\u0434\u043a\u043e\u0436\u0435\u043d\u043d\u044b\u043c <\/strong><code><strong>TENANT_ID<\/strong><\/code> \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442, \u043f\u043e\u0447\u0435\u043c\u0443 \u0432\u0441\u0435 \u0442\u0440\u0438 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u043d\u0443\u0436\u043d\u043e \u0437\u0430\u043a\u043b\u0430\u0434\u044b\u0432\u0430\u0442\u044c \u0441\u0440\u0430\u0437\u0443.<\/p>\n<p>&#8212; <strong>Operational tooling<\/strong>: read-only integrity check \u043d\u0430 \u0441\u0435\u043c\u044c \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439 \u0430\u043d\u043e\u043c\u0430\u043b\u0438\u0439 + schema drift detection \u0447\u0435\u0440\u0435\u0437 <code>alembic check<\/code> \u043d\u0430 \u0447\u0438\u0441\u0442\u043e\u0439 PG.<\/p>\n<p><strong>\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u21161: multi-tenancy \u0447\u0435\u0440\u0435\u0437 <\/strong><code><strong>tenant_id<\/strong><\/code><strong> column<\/strong><\/p>\n<p>\u0422\u0440\u0438 \u043a\u043b\u0430\u0441\u0441\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u043f\u043e\u0434\u0445\u043e\u0434\u0430 \u043a multi-tenancy \u0432 \u0440\u0435\u043b\u044f\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0411\u0414:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">\u0421\u0442\u0440\u0430\u0442\u0435\u0433\u0438\u044f<\/p>\n<\/th>\n<th>\n<p align=\"left\">\u0418\u0437\u043e\u043b\u044f\u0446\u0438\u044f<\/p>\n<\/th>\n<th>\n<p align=\"left\">Backup\/migrations<\/p>\n<\/th>\n<th>\n<p align=\"left\">\u0421\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c<\/p>\n<\/th>\n<th>\n<p align=\"left\">\u041a\u043e\u0433\u0434\u0430 \u043f\u043e\u0434\u0445\u043e\u0434\u0438\u0442<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Schema-per-tenant<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u041d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 Postgres<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u0421\u043b\u043e\u0436\u043d\u0430\u044f (N \u0441\u0445\u0435\u043c \u00d7 M \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0439)<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u0421\u0440\u0435\u0434\u043d\u044f\u044f<\/p>\n<\/td>\n<td>\n<p align=\"left\">5\u201350 enterprise \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432 \u0441 \u0440\u0430\u0437\u043d\u044b\u043c\u0438    SLA<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Database-per-tenant<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u0416\u0435\u043b\u0435\u0437\u043d\u0430\u044f<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u041e\u0447\u0435\u043d\u044c \u0441\u043b\u043e\u0436\u043d\u0430\u044f<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u0412\u044b\u0441\u043e\u043a\u0430\u044f<\/p>\n<\/td>\n<td>\n<p align=\"left\">Compliance-driven, \u226410 \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\">Shared DB + <code>tenant_id<\/code><\/p>\n<\/td>\n<td>\n<p align=\"left\">\u041d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u041f\u0440\u043e\u0441\u0442\u0430\u044f (\u043e\u0434\u043d\u0430 \u0441\u0445\u0435\u043c\u0430)<\/p>\n<\/td>\n<td>\n<p align=\"left\">\u041d\u0438\u0437\u043a\u0430\u044f<\/p>\n<\/td>\n<td>\n<p align=\"left\">SMB SaaS, \u0441\u043e\u0442\u043d\u0438\u2013\u0442\u044b\u0441\u044f\u0447\u0438   \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>\u042f \u0432\u044b\u0431\u0440\u0430\u043b \u0442\u0440\u0435\u0442\u0438\u0439 \u0432\u0430\u0440\u0438\u0430\u043d\u0442. \u041a\u0430\u0436\u0434\u0430\u044f \u0431\u0438\u0437\u043d\u0435\u0441-\u0442\u0430\u0431\u043b\u0438\u0446\u0430 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 <code>tenant_id TEXT NOT NULL<\/code> \u0441 \u0438\u043d\u0434\u0435\u043a\u0441\u043e\u043c, \u0432\u0441\u0435 \u0437\u0430\u043f\u0440\u043e\u0441\u044b \u043e\u0431\u044f\u0437\u0430\u043d\u044b \u0444\u0438\u043b\u044c\u0442\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u043d\u0435\u043c\u0443. \u0426\u0435\u043d\u0430 \u044d\u0442\u043e\u0439 \u043f\u0440\u043e\u0441\u0442\u043e\u0442\u044b \u2014 \u0435\u0434\u0438\u043d\u0441\u0442\u0432\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u0432 \u0444\u0438\u043b\u044c\u0442\u0440\u0435 \u043f\u0440\u0435\u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f \u0432 IDOR \u043d\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u043c\u0435\u0436\u0434\u0443 \u0442\u0435\u043d\u0430\u043d\u0442\u0430\u043c\u0438.<\/p>\n<p>\u0427\u0442\u043e\u0431\u044b \u044d\u0442\u043e \u043d\u0435 \u0437\u0430\u0431\u044b\u0432\u0430\u0442\u044c, \u0432 \u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0435\u0441\u0442\u044c \u0442\u043e\u043d\u043a\u0438\u0439 helper:<\/p>\n<pre><code class=\"python\">```python# app\/tenant_scope.pyfrom sqlalchemy import selectfrom sqlalchemy.sql import Selectdef scoped_select(model, user) -&gt; Select:    \"\"\"SELECT, \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d\u044b\u0439 \u0442\u0435\u043a\u0443\u0449\u0438\u043c \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u043c.    \u041f\u0430\u0434\u0430\u0435\u0442 PermissionError, \u0435\u0441\u043b\u0438 \u0443 user \u043d\u0435\u0442 tenant_id \u2014 \u044d\u0442\u043e    \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u043e: \u043b\u0443\u0447\u0448\u0435 500-\u043a\u0430 \u0432 \u043b\u043e\u0433\u0430\u0445, \u0447\u0435\u043c \u0442\u0438\u0445\u0430\u044f \u0443\u0442\u0435\u0447\u043a\u0430.    \"\"\"    if not user.tenant_id:        raise PermissionError(\"User has no tenant_id; refusing to query\")    return select(model).where(model.tenant_id == user.tenant_id)def scoped_get(db, model, id_, user):    return db.scalar(        select(model).where(model.id == id_, model.tenant_id == user.tenant_id)    )```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u041f\u0440\u0438\u043d\u0446\u0438\u043f \u2014 \u043b\u0443\u0447\u0448\u0435 \u044f\u0432\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430, \u0447\u0435\u043c \u0442\u0438\u0445\u0430\u044f \u0443\u0442\u0435\u0447\u043a\u0430. \u0418 \u0432 code review \u043f\u0440\u0430\u0432\u0438\u043b\u043e \u043f\u0440\u043e\u0441\u0442\u043e\u0435: \u043b\u044e\u0431\u043e\u0439 <code>select(BusinessModel)<\/code> \u0431\u0435\u0437 <code>scoped_select<\/code> \u2014 \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u044e\u0449\u0435\u0435 \u0437\u0430\u043c\u0435\u0447\u0430\u043d\u0438\u0435.<\/p>\n<p><strong>Bug story \u0432 callout: \u043e\u0434\u043d\u0430 \u0441\u0442\u0440\u043e\u043a\u0430, \u0434\u0432\u0430 \u0434\u043d\u044f \u0434\u0435\u0431\u0430\u0433\u0430<\/strong><\/p>\n<p>\u0412 \u043f\u0435\u0440\u0432\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043b closure <code>tenant_id<\/code> \u0438\u0437 config&#8217;\u0430 \u0432\u043e \u0432\u0441\u0435\u0445 write-handler&#8217;\u0430\u0445:<\/p>\n<pre><code class=\"python\">```python&gt; @router.post(\"\/v1\/doctors\")&gt; def create_doctor(payload, db, user):&gt;     item = Doctor(&gt;         id=f\"doctor-{uuid4()}\",&gt;         tenant_id=tenant_id,           # \u2190 closure \u0438\u0437 config!&gt;         **payload.model_dump(),&gt;     )&gt; ```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>Read-path \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043b <code>scoped_select(Doctor, user)<\/code> \u2014 \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e. Write-path \u0431\u0430\u043a\u043e\u0432\u0430\u043b <code>config.TENANT_ID<\/code>. \u041f\u043e\u043a\u0430 \u0442\u0435\u043d\u0430\u043d\u0442 \u043e\u0434\u0438\u043d \u2014 \u0440\u0430\u0441\u0441\u0438\u043d\u0445\u0440\u043e\u043d \u043d\u0435\u0432\u0438\u0434\u0438\u043c. \u041a\u043e\u0433\u0434\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u043b\u0438 \u0432\u0442\u043e\u0440\u0443\u044e \u043a\u043b\u0438\u043d\u0438\u043a\u0443, \u0435\u0451 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440 \u0432\u0438\u0434\u0435\u043b \u0432 \u0441\u043f\u0438\u0441\u043a\u0435 \u0432\u0440\u0430\u0447\u0435\u0439 \u043f\u0443\u0441\u0442\u043e, \u0445\u043e\u0442\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u043b \u0442\u0440\u0451\u0445. \u042d\u0442\u0438 \u0442\u0440\u043e\u0435 \u043b\u0435\u0436\u0430\u043b\u0438 \u0432 \u0411\u0414 \u0441 <code>tenant_id=\"tenant-1\"<\/code>. \u0424\u0438\u043a\u0441: <code>tenant_id=user.tenant_id<\/code> \u0438 \u0440\u0435\u0433\u0440\u0435\u0441\u0441\u0438\u044f \u0432 E2E \u00ab\u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0442\u0435\u043d\u0430\u043d\u0442\u0430 B \u2192 \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0441\u0443\u0449\u043d\u043e\u0441\u0442\u044c \u043a\u0430\u043a user-B \u2192 \u0437\u0430\u043b\u043e\u0433\u0438\u043d\u0438\u0442\u044c\u0441\u044f \u043a\u0430\u043a user-A \u2192 \u0443\u0431\u0435\u0434\u0438\u0442\u044c\u0441\u044f, \u0447\u0442\u043e \u043d\u0435 \u0432\u0438\u0434\u0435\u043d\u00bb.<\/p>\n<p>\u0423\u0440\u043e\u043a: helper \u043d\u0443\u0436\u0435\u043d \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043a\u043e\u043c\u043c\u0438\u0442\u0430, \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u0442\u0435\u043d\u0430\u043d\u0442 \u043e\u0434\u0438\u043d. \u041d\u0430 \u0434\u0435\u0441\u044f\u0442\u043e\u043c \u0442\u0435\u043d\u0430\u043d\u0442\u0435 \u043f\u043e\u0437\u0434\u043d\u043e \u2014 \u043f\u0440\u0438\u0434\u0451\u0442\u0441\u044f \u0440\u0435\u0432\u0438\u0437\u043e\u0432\u0430\u0442\u044c \u043a\u0430\u0436\u0434\u044b\u0439 endpoint.<\/p>\n<p><strong>\u0410 \u043f\u043e\u0447\u0435\u043c\u0443 \u043d\u0435 PostgreSQL Row-Level Security?<\/strong> \u042d\u0442\u043e \u043f\u0435\u0440\u0432\u044b\u0439 \u0432\u043e\u043f\u0440\u043e\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043c\u043d\u0435 \u0437\u0430\u0434\u0430\u044e\u0442, \u0438 \u043e\u043d \u0441\u043f\u0440\u0430\u0432\u0435\u0434\u043b\u0438\u0432\u044b\u0439. RLS \u0434\u0430\u043b \u0431\u044b tenant isolation \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u0411\u0414, \u0430 \u043d\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u2014 \u0442\u0435\u043e\u0440\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043d\u0430\u0434\u0451\u0436\u043d\u0435\u0435. \u041d\u0430 \u043f\u0440\u0430\u043a\u0442\u0438\u043a\u0435 RLS \u0442\u0440\u0435\u0431\u0443\u0435\u0442 <code>SET app.tenant_id = '...'<\/code> \u0432 \u043d\u0430\u0447\u0430\u043b\u0435 \u043a\u0430\u0436\u0434\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438, \u043b\u043e\u043c\u0430\u0435\u0442 stateless connection pooling (PgBouncer \u0432 transaction mode \u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0441\u043b\u043e\u0436\u043d\u0435\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c), \u0438 \u043f\u043b\u043e\u0445\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0441 migrations \u0438 admin-\u0437\u0430\u0434\u0430\u0447\u0430\u043c\u0438, \u0433\u0434\u0435 \u0445\u043e\u0447\u0435\u0442\u0441\u044f \u043e\u0431\u043e\u0439\u0442\u0438 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435 \u043b\u0435\u0433\u0430\u043b\u044c\u043d\u043e. \u0414\u043b\u044f solo-\u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u043d\u0430 \u0440\u0430\u043d\u043d\u0435\u0439 \u0441\u0442\u0430\u0434\u0438\u0438 overhead RLS \u043d\u0435 \u043e\u043f\u0440\u0430\u0432\u0434\u0430\u043d \u2014 <code>scoped_select<\/code> + lint-\u043f\u0440\u0430\u0432\u0438\u043b\u043e \u0432 code review \u0434\u0430\u0451\u0442 95% \u0437\u0430\u0449\u0438\u0442\u044b \u043f\u0440\u0438 5% \u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438. \u041d\u0430 \u043f\u043e\u0437\u0434\u043d\u0435\u0439 \u0441\u0442\u0430\u0434\u0438\u0438 (\u0434\u0435\u0441\u044f\u0442\u043a\u0438 enterprise-\u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432 \u0441 compliance-\u0442\u0440\u0435\u0431\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438) RLS \u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0440\u0430\u0437\u0443\u043c\u043d\u044b\u043c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435\u043c, \u043d\u0435 \u0437\u0430\u043c\u0435\u043d\u043e\u0439.<\/p>\n<p><strong>\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u21162: PostgreSQL <\/strong><code><strong>EXCLUDE USING gist<\/strong><\/code><strong> \u043f\u0440\u043e\u0442\u0438\u0432 double-booking<\/strong><\/p>\n<p>\u0414\u0432\u0430 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0431\u0440\u043e\u043d\u0438\u0440\u0443\u044e\u0442 \u043e\u0434\u0438\u043d \u0438 \u0442\u043e\u0442 \u0436\u0435 \u0441\u043b\u043e\u0442 \u0443 \u043e\u0434\u043d\u043e\u0433\u043e \u0432\u0440\u0430\u0447\u0430. \u041e\u0431\u0430 \u0432\u0438\u0434\u044f\u0442 \u0441\u043b\u043e\u0442 \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u044b\u043c. \u041e\u0431\u0430 \u0432\u044b\u0437\u044b\u0432\u0430\u044e\u0442 <code>POST \/appointments<\/code>. \u041e\u0431\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u044e\u0442 <code>200 OK<\/code>. \u0414\u0432\u043e\u0439\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c.<\/p>\n<p>\u0417\u0430\u0449\u0438\u0442\u0430 \u00ab\u0432 \u043a\u043e\u0434\u0435\u00bb (<code>SELECT<\/code> \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u2192 <code>INSERT<\/code>) \u043f\u043e\u0434 \u043f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u043e\u0439 \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u043e\u0439 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442. \u041c\u0435\u0436\u0434\u0443 \u0434\u0432\u0443\u043c\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043c\u0438 \u0432 \u043e\u0434\u043d\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438 \u0434\u0440\u0443\u0433\u0430\u044f \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f \u0443\u0441\u043f\u0435\u0432\u0430\u0435\u0442 \u0432\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043f\u0435\u0440\u0435\u0441\u0435\u043a\u0430\u044e\u0449\u0438\u0439\u0441\u044f \u0441\u043b\u043e\u0442. \u042f \u044d\u0442\u043e \u0443\u0432\u0438\u0434\u0435\u043b \u043d\u0430 \u043f\u0435\u0440\u0432\u043e\u043c \u0436\u0435 load-\u0442\u0435\u0441\u0442\u0435.<\/p>\n<p>\u042d\u0432\u043e\u043b\u044e\u0446\u0438\u044f \u0437\u0430\u0449\u0438\u0442\u044b \u043d\u0430 \u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0448\u043b\u0430 \u0432 \u0442\u0440\u0438 \u0441\u0442\u0430\u0434\u0438\u0438. \u041f\u0440\u0438\u0432\u043e\u0436\u0443 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0435 \u0446\u0438\u0444\u0440\u044b \u0438\u0437 benchmark&#8217;\u043e\u0432 \u043d\u0430 PostgreSQL 16, single-node:<\/p>\n<p><strong>\u0421\u0442\u0430\u0434\u0438\u044f 1 \u2014 application-level check.<\/strong> 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u043d\u0430 \u043e\u0434\u0438\u043d \u0441\u043b\u043e\u0442 \u2192 <strong>4\u00d7200, 8\u00d7409<\/strong>. \u0412\u0438\u0434\u0438\u043c\u044b \u0434\u0432\u043e\u0439\u043d\u044b\u0435 \u0437\u0430\u043f\u0438\u0441\u0438. <code>create_unique_appointments<\/code> p95 = 26ms.<\/p>\n<p><strong>\u0421\u0442\u0430\u0434\u0438\u044f 2 \u2014 transactional advisory locks<\/strong> (<code>pg_advisory_xact_lock<\/code> \u043f\u043e <code>(doctor_id, time_window)<\/code> \u043f\u0435\u0440\u0435\u0434 INSERT&#8217;\u043e\u043c). 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u2192 <strong>1\u00d7200, 11\u00d7409<\/strong>. Race-condition \u0437\u0430\u043a\u0440\u044b\u0442, \u043d\u043e <code>create_unique_appointments<\/code> p95 \u043f\u043e\u0434\u043d\u044f\u043b\u0441\u044f \u0434\u043e <strong>36.86ms<\/strong> \u0438\u0437-\u0437\u0430 serialization overhead.<\/p>\n<p><strong>\u0421\u0442\u0430\u0434\u0438\u044f 3 \u2014 hard DB invariant \u0447\u0435\u0440\u0435\u0437 <\/strong><code><strong>EXCLUDE USING gist<\/strong><\/code><strong>.<\/strong> \u0422\u0435 \u0436\u0435 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u2192 <strong>1\u00d7200, 11\u00d7409<\/strong>, <code>create_unique_appointments<\/code> p95 = <strong>31.69ms<\/strong>, conflict-path p95 = <strong>11.22ms<\/strong>. \u0417\u0430\u0449\u0438\u0442\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u043b\u0430\u0441\u044c \u0441 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432 schema, advisory lock \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043d\u0443\u0436\u0435\u043d.<\/p>\n<p>DDL constraint:<\/p>\n<pre><code class=\"sql\">``sql-- Alembic upgradeCREATE EXTENSION IF NOT EXISTS btree_gist;ALTER TABLE appointments ADD CONSTRAINT appointments_doctor_slot_excl  EXCLUDE USING gist (    doctor_id WITH =,    tsrange(starts_at, ends_at, '[)') WITH &amp;&amp;  )  WHERE (status NOT IN ('cancelled', 'no_show'));```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>\u0427\u0442\u043e \u0437\u0434\u0435\u0441\u044c \u043f\u0440\u043e\u0438\u0441\u0445\u043e\u0434\u0438\u0442:<\/p>\n<p>&#8212; <code>doctor_id WITH =<\/code> \u2014 \u0434\u0432\u0430 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u0430 \u0440\u0430\u0441\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u043a\u0430\u043a \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u0443\u044e\u0449\u0438\u0435, \u0442\u043e\u043b\u044c\u043a\u043e \u0435\u0441\u043b\u0438 \u0443 \u043d\u0438\u0445 \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u044b\u0439 <code>doctor_id<\/code>.<\/p>\n<p>&#8212; <code>tsrange(starts_at, ends_at, '[)')<\/code> \u2014 \u043f\u043e\u043b\u0443\u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0439 \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b; \u0437\u0430\u043f\u0438\u0441\u044c 10:00\u201310:30 \u0438 10:30\u201311:00 \u043d\u0435 \u043f\u0435\u0440\u0435\u0441\u0435\u043a\u0430\u044e\u0442\u0441\u044f.<\/p>\n<p>&#8212; <code>&amp;&amp;<\/code> \u2014 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u043f\u0435\u0440\u0435\u0441\u0435\u0447\u0435\u043d\u0438\u044f \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u043e\u0432.<\/p>\n<p>&#8212; <code>WHERE (status NOT IN ('cancelled', 'no_show'))<\/code> \u2014 \u043e\u0442\u043c\u0435\u043d\u0451\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u044f\u0432\u043a\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u044b, \u043f\u0430\u0446\u0438\u0435\u043d\u0442 \u043f\u043e\u0441\u043b\u0435 \u043e\u0442\u043c\u0435\u043d\u044b \u043c\u043e\u0436\u0435\u0442 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f \u0432 \u0442\u043e\u0442 \u0436\u0435 \u0441\u043b\u043e\u0442.<\/p>\n<p><code>btree_gist<\/code> \u043d\u0443\u0436\u0435\u043d, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e <code>=<\/code> \u0434\u043b\u044f \u0441\u043a\u0430\u043b\u044f\u0440\u043d\u043e\u0433\u043e <code>doctor_id<\/code> \u0447\u0435\u0440\u0435\u0437 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 gist-\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442; \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u0435\u0442 btree-\u0441\u0435\u043c\u0430\u043d\u0442\u0438\u043a\u0443 \u0432\u043d\u0443\u0442\u0440\u044c gist-\u0438\u043d\u0434\u0435\u043a\u0441\u0430.<\/p>\n<p>\u041c\u0430\u043f\u043f\u0438\u043d\u0433 \u043e\u0448\u0438\u0431\u043a\u0438 \u0432 HTTP \u0432 FastAPI:<\/p>\n<pre><code class=\"python\">``pythonfrom psycopg.errors import ExclusionViolationtry:    db.add(appointment)    db.flush()except IntegrityError as exc:    if isinstance(exc.orig, ExclusionViolation):        raise HTTPException(            status_code=409,            detail={                \"code\": \"APPOINTMENT_SLOT_CONFLICT\",                \"message\": \"\u0421\u043b\u043e\u0442 \u0437\u0430\u043d\u044f\u0442 \u0434\u0440\u0443\u0433\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e\",            },        )    raise```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code><strong>tsrange<\/strong><\/code><strong> \u0438\u043b\u0438 <\/strong><code><strong>tstzrange<\/strong><\/code><strong>?<\/strong> \u042f \u0432\u044b\u0431\u0440\u0430\u043b <code>tsrange<\/code> (naive timestamp \u0431\u0435\u0437 timezone), \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u0432 \u044d\u0442\u043e\u043c \u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0432\u0441\u0435 timestamp&#8217;\u044b \u0445\u0440\u0430\u043d\u044f\u0442\u0441\u044f \u0432 UTC, \u0430 \u043a\u043e\u043d\u0432\u0435\u0440\u0441\u0438\u044f \u0432 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f \u043a\u043b\u0438\u043d\u0438\u043a\u0438 \u0434\u0435\u043b\u0430\u0435\u0442\u0441\u044f \u043d\u0430 app-\u0443\u0440\u043e\u0432\u043d\u0435 \u0447\u0435\u0440\u0435\u0437 <code>tenant.timezone<\/code>. <code>tstzrange<\/code> \u0440\u0430\u0431\u043e\u0442\u0430\u043b \u0431\u044b, \u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u043b \u0431\u044b \u043b\u0438\u0448\u043d\u0438\u0439 \u0441\u043b\u043e\u0439 \u043d\u0435\u044f\u0432\u043d\u044b\u0445 \u043a\u043e\u043d\u0432\u0435\u0440\u0441\u0438\u0439 \u0438 \u043f\u043e\u0442\u0435\u043d\u0446\u0438\u0430\u043b\u044c\u043d\u044b\u0445 \u0431\u0430\u0433\u043e\u0432 \u0441 DST. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u043f\u043e\u043b\u0438\u0442\u0438\u043a\u0430 \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u00abtimestamp with timezone \u0432\u0435\u0437\u0434\u0435\u00bb \u2014 \u0431\u0435\u0440\u0438\u0442\u0435 <code>tstzrange<\/code>, \u0440\u0430\u0437\u043d\u0438\u0446\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0442\u043e\u043c, \u043a\u0430\u043a Postgres \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0435 \u0442\u0440\u0430\u043a\u0442\u0443\u0435\u0442 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u044b.<\/p>\n<p><strong>\u041f\u043e\u0434\u0432\u043e\u0434\u043d\u044b\u0435 \u043a\u0430\u043c\u043d\u0438:<\/strong><\/p>\n<p>&#8212; <code>EXCLUDE<\/code> \u0441\u043e\u0437\u0434\u0430\u0451\u0442 gist-\u0438\u043d\u0434\u0435\u043a\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0440\u0430\u0441\u0442\u0451\u0442 \u0431\u044b\u0441\u0442\u0440\u0435\u0435 B-tree \u043d\u0430 \u0431\u043e\u043b\u044c\u0448\u0438\u0445 \u043e\u0431\u044a\u0451\u043c\u0430\u0445. \u041f\u043e [\u0441\u0442\u0430\u0442\u044c\u0435 \u043d\u0430 \u0425\u0430\u0431\u0440\u0435 \u043f\u0440\u043e btree_gist benchmark](<a href=\"https:\/\/habr.com\/ru\/articles\/820455\/\" rel=\"noopener noreferrer nofollow\">https:\/\/habr.com\/ru\/articles\/820455\/<\/a>) \u2014 \u0434\u0435\u0433\u0440\u0430\u0434\u0430\u0446\u0438\u044f \u0437\u0430\u043f\u0438\u0441\u0438 \u0434\u043e 2\u00d7 \u0438 \u0447\u0442\u0435\u043d\u0438\u044f \u0434\u043e 20% \u043d\u0430 \u0433\u043e\u0440\u044f\u0447\u0438\u0445 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0445. \u0414\u043b\u044f \u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0437\u0430\u043f\u0438\u0441\u0435\u0439 \u043f\u0430\u0446\u0438\u0435\u043d\u0442\u043e\u0432 \u044d\u0442\u043e \u043e\u043a\u0435\u0439 (\u043a\u0430\u0440\u0434\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u043d\u0435\u0432\u044b\u0441\u043e\u043a\u0430\u044f), \u0434\u043b\u044f \u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0441 \u043c\u0438\u043b\u043b\u0438\u043e\u043d\u0430\u043c\u0438 \u0441\u0442\u0440\u043e\u043a \u2014 \u0437\u0430\u0434\u0443\u043c\u0430\u0439\u0442\u0435\u0441\u044c.<\/p>\n<p>&#8212; <code>WHERE<\/code>-\u043a\u043b\u0430\u0443\u0437\u0430 partial constraint \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 <code>INSERT<\/code>\/<code>DELETE<\/code>. Cancellation \u0434\u043e\u043b\u0436\u043d\u0430 \u0431\u044b\u0442\u044c <code>UPDATE status='cancelled'<\/code>, \u043d\u0435 <code>DELETE<\/code> \u2014 \u0438\u043d\u0430\u0447\u0435 \u0437\u0430\u043f\u0438\u0441\u044c \u00ab\u0438\u0441\u0447\u0435\u0437\u0430\u0435\u0442\u00bb, \u0438 \u0441\u043b\u043e\u0442 \u0447\u0435\u0440\u0435\u0437 \u0441\u0435\u043a\u0443\u043d\u0434\u0443 \u0437\u0430\u043d\u0438\u043c\u0430\u0435\u0442 \u043a\u0442\u043e-\u0442\u043e \u0435\u0449\u0451, \u0430 \u043e\u0442\u043c\u0435\u043d\u0451\u043d\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u043d\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c.<\/p>\n<p>&#8212; \u0412 \u0442\u0435\u0441\u0442\u0430\u0445 \u043d\u0443\u0436\u0435\u043d \u0442\u043e\u0442 \u0436\u0435 \u0434\u0438\u0430\u043b\u0435\u043a\u0442 (PG, \u043d\u0435 SQLite). SQLite \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u0435\u0442 DDL \u0431\u0435\u0437 \u043e\u0448\u0438\u0431\u043e\u043a, \u043d\u043e \u043d\u0438\u043a\u0430\u043a\u0438\u0445 exclusion constraint \u043d\u0435 \u0441\u043e\u0437\u0434\u0430\u0451\u0442 \u2014 \u0431\u0430\u0433 \u0432 \u0442\u0435\u0441\u0442\u0430\u0445 \u043d\u0435 \u043e\u0442\u043b\u043e\u0432\u0438\u0442\u044c.<\/p>\n<p>\u0427\u0442\u043e \u044f \u043f\u043e\u043b\u0443\u0447\u0438\u043b \u0432 \u0438\u0442\u043e\u0433\u0435: \u043f\u0440\u0438 \u0440\u043e\u0441\u0442\u0435 c 1 \u0434\u043e 10 \u043a\u043b\u0438\u043d\u0438\u043a \u0432 \u043f\u0440\u043e\u0434\u0435 \u043d\u0438 \u043e\u0434\u043d\u043e\u0439 \u0434\u0432\u043e\u0439\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438. \u041d\u0435 \u043d\u0443\u0436\u0435\u043d Redis, \u043d\u0435 \u043d\u0443\u0436\u0435\u043d distributed lock, \u043d\u0435 \u043d\u0443\u0436\u043d\u043e application-level retry. Pos\u00adtgreSQL \u0434\u0435\u043b\u0430\u0435\u0442 \u0432\u0441\u044e \u0440\u0430\u0431\u043e\u0442\u0443.<\/p>\n<p><strong>\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u21163: 152-\u0424\u0417 \u043a\u0430\u043a \u043a\u043e\u0434<\/strong><\/p>\n<p>\u0414\u043b\u044f \u043c\u0435\u0434\u0438\u0446\u0438\u043d\u0441\u043a\u0438\u0445 \u0434\u0430\u043d\u043d\u044b\u0445 \u0432 \u0420\u0424 152-\u0424\u0417 \u0442\u0440\u0435\u0431\u0443\u0435\u0442: \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u0432 \u0440\u043e\u0441\u0441\u0438\u0439\u0441\u043a\u0438\u0445 \u0426\u041e\u0414\u0430\u0445, \u0437\u0430\u0449\u0438\u0442\u044b \u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0440\u0438 \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0435 \u0438 \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0438, \u0436\u0443\u0440\u043d\u0430\u043b\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u041f\u0414\u043d, \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u0441\u043e\u0433\u043b\u0430\u0441\u0438\u044f \u043d\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443 \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u044c\u043d\u044b\u0445 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439, \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f\/\u0430\u043d\u043e\u043d\u0438\u043c\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443. \u0427\u0442\u043e \u0438\u0437 \u044d\u0442\u043e\u0433\u043e \u043f\u0440\u0435\u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f \u0432 \u043a\u043e\u0434:<\/p>\n<p><strong>\u0428\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0430\u0440\u043e\u043b\u0435\u0439: \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f PBKDF2 \u2192 argon2id \u0431\u0435\u0437 forced reset<\/strong><\/p>\n<p>\u0418\u0437\u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e \u043f\u0430\u0440\u043e\u043b\u0438 \u0445\u0435\u0448\u0438\u043b\u0438\u0441\u044c \u0447\u0435\u0440\u0435\u0437 <code>PBKDF2-HMAC-SHA256<\/code> \u0441\u043e 120 000 \u0438\u0442\u0435\u0440\u0430\u0446\u0438\u0439 \u2014 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442 OWASP \u0434\u043e 2023 \u0433\u043e\u0434\u0430. \u0421\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f OWASP\/NIST \u2014 argon2id (memory-hard, \u0443\u0441\u0442\u043e\u0439\u0447\u0438\u0432\u0435\u0435 \u043a GPU-\u0430\u0442\u0430\u043a\u0430\u043c). \u0420\u0435\u0437\u043a\u0430\u044f \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f \u0447\u0435\u0440\u0435\u0437 forced password reset \u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u0430 \u0432 B2B (\u0434\u0435\u0441\u044f\u0442\u043a\u0438 \u0441\u043e\u0442\u0440\u0443\u0434\u043d\u0438\u043a\u043e\u0432 \u043a\u043b\u0438\u043d\u0438\u043a\u0438, \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440 \u043d\u0435 \u0441\u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043b\u043e\u0433\u0438\u043d\u0438\u0442\u044c\u0441\u044f \u0443\u0442\u0440\u043e\u043c \u043f\u043e\u043d\u0435\u0434\u0435\u043b\u044c\u043d\u0438\u043a\u0430), \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u0430\u044f \u0441\u0445\u0435\u043c\u0430: \u043d\u043e\u0432\u044b\u0435 \u043f\u0430\u0440\u043e\u043b\u0438 \u043f\u0438\u0448\u0443\u0442\u0441\u044f \u043a\u0430\u043a argon2id, \u0441\u0442\u0430\u0440\u044b\u0435 \u0432\u0435\u0440\u0438\u0444\u0438\u0446\u0438\u0440\u0443\u044e\u0442\u0441\u044f \u043f\u043e \u0441\u0432\u043e\u0435\u043c\u0443 \u0444\u043e\u0440\u043c\u0430\u0442\u0443, \u043f\u0440\u0438 \u0443\u0441\u043f\u0435\u0448\u043d\u043e\u043c \u043b\u043e\u0433\u0438\u043d\u0435 \u043c\u043e\u043b\u0447\u0430 \u043f\u0435\u0440\u0435\u0445\u0435\u0448\u0438\u0440\u0443\u044e\u0442\u0441\u044f \u0432 argon2id.<\/p>\n<p>\u041a\u043b\u044e\u0447 \u2014 \u0434\u0435\u0442\u0435\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0444\u043e\u0440\u043c\u0430\u0442\u0430 \u043f\u043e \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u0443 \u0445\u0435\u0448\u0430:<\/p>\n<pre><code class=\"python\">```pythonimport hashlibimport secretsfrom argon2 import PasswordHasherfrom argon2 import exceptions as argon2_exceptionsPASSWORD_ITERATIONS = 120_000  # legacy PBKDF2 \u0434\u043b\u044f backward-compatPASSWORD_PREFIX = \"pbkdf2_sha256\"ARGON2_PREFIX = \"$argon2\"PASSWORD_HASHER = PasswordHasher()  # argon2id \u0441 \u0434\u0435\u0444\u043e\u043b\u0442\u043d\u044b\u043c\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043c\u0438def hash_password(password: str) -&gt; str:    \"\"\"\u0412\u0441\u0435 \u043d\u043e\u0432\u044b\u0435 \u043f\u0430\u0440\u043e\u043b\u0438 \u2014 argon2id.\"\"\"    return PASSWORD_HASHER.hash(password)def verify_password(password: str, encoded: str) -&gt; bool:    if encoded.startswith(ARGON2_PREFIX):        try:            return PASSWORD_HASHER.verify(encoded, password)        except (argon2_exceptions.VerifyMismatchError,                argon2_exceptions.InvalidHashError):            return False    if encoded.startswith(f\"{PASSWORD_PREFIX}$\"):        _, iterations, salt, digest = encoded.split(\"$\", 3)        computed = hashlib.pbkdf2_hmac(            \"sha256\", password.encode(), salt.encode(), int(iterations)        )        return secrets.compare_digest(computed.hex(), digest)    return Falsedef password_needs_rehash(encoded: str) -&gt; bool:    if encoded.startswith(f\"{PASSWORD_PREFIX}$\"):        return True  # \u043b\u044e\u0431\u043e\u0439 PBKDF2 \u2192 upgrade \u0432 argon2id    if encoded.startswith(ARGON2_PREFIX):        return PASSWORD_HASHER.check_needs_rehash(encoded)    return False```\u0412 `\/auth\/login` \u043f\u043e\u0441\u043b\u0435 \u0443\u0441\u043f\u0435\u0448\u043d\u043e\u0439 \u0432\u0435\u0440\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438:```pythonif password_needs_rehash(user.password_hash):    user.password_hash = hash_password(plain_password)    db.commit()```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><strong>\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b argon2id \u0438 trade-off.**<\/strong> \u042f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u044e \u0434\u0435\u0444\u043e\u043b\u0442\u044b <code>argon2-cffi<\/code>: <code>memory_cost=65536<\/code> (64 MiB), <code>time_cost=3<\/code>, <code>parallelism=4<\/code>. \u041d\u0430 \u043c\u043e\u0451\u043c VPS \u044d\u0442\u043e \u0434\u0430\u0451\u0442 login latency \u043e\u043a\u043e\u043b\u043e 80ms \u2014 \u043f\u0440\u0438\u0435\u043c\u043b\u0435\u043c\u043e \u0434\u043b\u044f \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f, \u0434\u043e\u0440\u043e\u0433\u043e \u0434\u043b\u044f \u0430\u0442\u0430\u043a\u0443\u044e\u0449\u0435\u0433\u043e (\u0431\u0440\u0443\u0442\u0444\u043e\u0440\u0441 \u043d\u0430 GPU \u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u043d\u0435\u043f\u0440\u0430\u043a\u0442\u0438\u0447\u043d\u044b\u043c \u0438\u0437-\u0437\u0430 memory-bound nature \u0430\u043b\u0433\u043e\u0440\u0438\u0442\u043c\u0430). \u0415\u0441\u043b\u0438 \u043f\u0438\u043b\u043e\u0442\u043d\u044b\u0445 \u043a\u043b\u0438\u043d\u0438\u043a \u0431\u0443\u0434\u0435\u0442 \u043f\u043e\u0434 100 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u043b\u043e\u0433\u0438\u043d\u0438\u0442\u044c\u0441\u044f, \u043c\u043e\u0436\u043d\u043e \u0441\u043d\u0438\u0437\u0438\u0442\u044c <code>memory_cost<\/code> \u0434\u043e 32 MiB, \u043d\u043e \u043f\u043e\u043a\u0430 \u044f \u0432 \u044d\u0442\u043e \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435 \u0434\u0430\u0436\u0435 \u0431\u043b\u0438\u0437\u043a\u043e \u043d\u0435 \u0443\u043f\u0451\u0440\u0441\u044f.<\/p>\n<p><strong>\u041f\u043e\u0447\u0435\u043c\u0443 argon2id, \u0430 \u043d\u0435 bcrypt \u0438\u043b\u0438 scrypt?<\/strong> bcrypt \u0441\u0442\u0430\u0431\u0438\u043b\u0435\u043d, \u043d\u043e \u043d\u0435 memory-hard \u0438 \u043f\u0440\u043e\u0438\u0433\u0440\u044b\u0432\u0430\u0435\u0442 GPU-\u0444\u0435\u0440\u043c\u0430\u043c. scrypt memory-hard, \u043d\u043e \u043c\u0435\u043d\u0435\u0435 tunable \u043c\u0435\u0436\u0434\u0443 tradeoff&#8217;\u0430\u043c\u0438 latency\/memory. argon2id \u2014 \u043f\u043e\u0431\u0435\u0434\u0438\u0442\u0435\u043b\u044c Password Hashing Competition 2015, \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f OWASP \u0441 2023, \u0438 \u0435\u0433\u043e \u043f\u043e\u0434\u0445\u043e\u0434 \u00abhybrid\u00bb \u043a\u043e\u043c\u0431\u0438\u043d\u0438\u0440\u0443\u0435\u0442 \u0443\u0441\u0442\u043e\u0439\u0447\u0438\u0432\u043e\u0441\u0442\u044c argon2i \u043a side-channel \u0430\u0442\u0430\u043a\u0430\u043c \u0441 GPU-resistance argon2d. \u0414\u043b\u044f \u0440\u0435\u0433\u0443\u043b\u0438\u0440\u0443\u0435\u043c\u043e\u0439 B2B \u044d\u0442\u043e \u0440\u0430\u0437\u0443\u043c\u043d\u044b\u0439 default.<\/p>\n<p><strong>Audit log \u0441 \u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0435\u0439 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439 \u041f\u0414\u043d<\/strong><\/p>\n<p>\u041a\u0430\u0436\u0434\u0430\u044f \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u044f \u0441 \u0434\u0430\u043d\u043d\u044b\u043c\u0438 \u043f\u0430\u0446\u0438\u0435\u043d\u0442\u0430 \u043f\u0438\u0448\u0435\u0442\u0441\u044f \u0432 <code>audit_logs<\/code> \u0441 \u043f\u043e\u043b\u0435\u043c <code>data_category<\/code>. \u042d\u0442\u043e\u0442 enum \u043d\u0443\u0436\u0435\u043d, \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438 \u0437\u0430\u043f\u0440\u043e\u0441\u0435 \u0441\u0443\u0431\u044a\u0435\u043a\u0442\u0430 (\u0438\u043b\u0438 \u0420\u043e\u0441\u043a\u043e\u043c\u043d\u0430\u0434\u0437\u043e\u0440\u0430) \u043c\u043e\u0436\u043d\u043e \u0431\u044b\u043b\u043e \u043e\u0434\u043d\u0438\u043c SELECT&#8217;\u043e\u043c \u0432\u044b\u0433\u0440\u0443\u0437\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u0440\u0435\u043b\u0435\u0432\u0430\u043d\u0442\u043d\u044b\u0435 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438, \u0430 \u043d\u0435 \u043f\u0430\u0440\u0441\u0438\u0442\u044c <code>entity_type<\/code>\/<code>action<\/code> \u043f\u043e \u0441\u0442\u0440\u043e\u043a\u0430\u043c:<\/p>\n<pre><code class=\"python\">```pythonclass AuditLog(Base):    __tablename__ = \"audit_logs\"    id: Mapped[str] = mapped_column(String, primary_key=True)    tenant_id: Mapped[str] = mapped_column(String, index=True)    actor_user_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)    entity_type: Mapped[str] = mapped_column(String)    entity_id: Mapped[str] = mapped_column(String)    action: Mapped[str] = mapped_column(String)    # 152-\u0424\u0417 \u0441\u0442. 10 \u043f. 2 \u2014 \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u0430\u044f \u043c\u0430\u0440\u043a\u0438\u0440\u043e\u0432\u043a\u0430 \u0441\u043f\u0435\u0446.\u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439 \u041f\u0414\u043d.    data_category: Mapped[str] = mapped_column(String, default=\"general\", index=True)    before_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)    after_json: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)    ip: Mapped[Optional[str]] = mapped_column(String, nullable=True)    created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow)    __table_args__ = (        Index(\"ix_audit_logs_tenant_created\", \"tenant_id\", text(\"created_at DESC\")),    )````data_category` \u2014 \u044d\u043d\u0443\u043c \u043d\u0430 \u0442\u0440\u0438 \u0437\u043d\u0430\u0447\u0435\u043d\u0438\u044f, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043f\u0440\u043e\u0441\u0442\u0430\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u043d\u0435 \u0432\u0440\u0443\u0447\u043d\u0443\u044e, \u0430 \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u043c \u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c \u043f\u043e `entity_type`:```python_AUDIT_SPECIAL_HEALTH_ENTITIES = frozenset({    \"patient\", \"patient_consent\",    \"appointment\", \"encounter\", \"notification\",    \"compliance_retention\",})_AUDIT_IDENTITY_ENTITIES = frozenset({\"user\", \"user_group\"})def _classify_audit_entity(entity_type: str) -&gt; str:    if entity_type in _AUDIT_SPECIAL_HEALTH_ENTITIES:        return \"special_health\"    if entity_type in _AUDIT_IDENTITY_ENTITIES:        return \"identity\"    return \"general\"```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p>special_health<code> \u0432\u043a\u043b\u044e\u0447\u0430\u0435\u0442 \u043d\u0435 \u0442\u043e\u043b\u044c\u043a\u043e \u0434\u0438\u0430\u0433\u043d\u043e\u0437\u044b \u2014 \u0442\u0443\u0434\u0430 \u043f\u043e\u043f\u0430\u0434\u0430\u044e\u0442 <\/code>appointment<code> \u0438 <\/code>notification`, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u0441\u0430\u043c \u0444\u0430\u043a\u0442 \u0437\u0430\u043f\u0438\u0441\u0438 \u043a \u043a\u043e\u043d\u043a\u0440\u0435\u0442\u043d\u043e\u043c\u0443 \u0432\u0440\u0430\u0447\u0443 + \u043f\u0440\u0438\u0447\u0438\u043d\u0430 \u043e\u0431\u0440\u0430\u0449\u0435\u043d\u0438\u044f \u0443\u0436\u0435 \u0441\u0447\u0438\u0442\u0430\u044e\u0442\u0441\u044f \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u044c\u043d\u043e\u0439 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0435\u0439 \u041f\u0414\u043d \u0432 \u0441\u0442\u0440\u043e\u0433\u043e\u0439 \u0442\u0440\u0430\u043a\u0442\u043e\u0432\u043a\u0435 152-\u0424\u0417 \u0441\u0442. 10. Conservative-\u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u043b\u0443\u0447\u0448\u0435, \u0447\u0435\u043c \u043d\u0435\u0434\u043e\u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f: \u0435\u0441\u043b\u0438 \u0440\u0435\u0433\u0443\u043b\u044f\u0442\u043e\u0440 \u0441\u043f\u0440\u043e\u0441\u0438\u0442 \u00ab\u043a\u0430\u043a\u0438\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u043e \u0437\u0434\u043e\u0440\u043e\u0432\u044c\u0435 \u0432\u044b \u0441\u043e\u0431\u0438\u0440\u0430\u0435\u0442\u0435\u00bb \u2014 \u043b\u0443\u0447\u0448\u0435 \u043e\u0448\u0438\u0431\u0438\u0442\u044c\u0441\u044f \u0432 \u0441\u0442\u043e\u0440\u043e\u043d\u0443 \u00ab\u0431\u043e\u043b\u044c\u0448\u0435\u00bb, \u0447\u0435\u043c \u00ab\u043c\u0435\u043d\u044c\u0448\u0435\u00bb.<\/p>\n<p><strong>\u0427\u0435\u043c 152-\u0424\u0417 \u043e\u0442\u043b\u0438\u0447\u0430\u0435\u0442\u0441\u044f \u043e\u0442 GDPR.<\/strong> \u041e\u0434\u0438\u043d \u0432 \u043e\u0434\u0438\u043d \u0448\u0430\u0431\u043b\u043e\u043d \u043d\u0435 \u043f\u0435\u0440\u0435\u043d\u043e\u0441\u0438\u0442\u0441\u044f: 152-\u0424\u0417 \u0431\u043b\u0438\u0436\u0435 \u043a \u0430\u043c\u0435\u0440\u0438\u043a\u0430\u043d\u0441\u043a\u043e\u043c\u0443 HIPAA \u0432 \u0442\u043e\u043c, \u0447\u0442\u043e \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e \u0432\u044b\u0434\u0435\u043b\u044f\u0435\u0442 \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u044c\u043d\u044b\u0435 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0438 \u0441 \u0434\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u043c\u0438 \u0442\u0440\u0435\u0431\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438 \u043a \u0441\u043e\u0433\u043b\u0430\u0441\u0438\u044e \u0438 \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044e, \u043f\u043b\u044e\u0441 residency-\u0442\u0440\u0435\u0431\u043e\u0432\u0430\u043d\u0438\u0435 (\u0442\u043e\u043b\u044c\u043a\u043e \u0420\u0424-\u0426\u041e\u0414\u044b) \u0438 \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u0430\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u044f \u0432 \u0440\u0435\u0435\u0441\u0442\u0440\u0435 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440\u043e\u0432 \u041f\u0414\u043d \u0432 \u0420\u041a\u041d. GDPR-\u0444\u0440\u0435\u0439\u043c\u0432\u043e\u0440\u043a controller\/processor\/lawful-basis \u043f\u043e \u0444\u043e\u0440\u043c\u0435 \u043f\u043e\u0445\u043e\u0436, \u043d\u043e \u0440\u0435\u0433\u0443\u043b\u044f\u0442\u043e\u0440 \u0438 \u0431\u0443\u043c\u0430\u0436\u043a\u0438 \u0434\u0440\u0443\u0433\u0438\u0435.<\/p>\n<p><strong>Operational tooling<\/strong><\/p>\n<p>\u0414\u0432\u0430 \u0438\u043d\u0441\u0442\u0440\u0443\u043c\u0435\u043d\u0442\u0430, \u0431\u0435\u0437 \u043a\u043e\u0442\u043e\u0440\u044b\u0445 \u044f \u0431\u044b \u043d\u0435 \u0434\u043e\u0432\u0435\u0440\u044f\u043b \u0411\u0414 \u043f\u0438\u043b\u043e\u0442\u043d\u043e\u0433\u043e \u043f\u0440\u043e\u0435\u043a\u0442\u0430.<\/p>\n<p><strong>1. Read-only integrity check.<\/strong> \u0421\u043a\u0440\u0438\u043f\u0442 \u043d\u0430 \u0441\u0435\u043c\u044c \u0441\u0442\u0440\u0430\u0442\u0435\u0433\u0438\u0439, \u043a\u0430\u0436\u0434\u0430\u044f \u043b\u043e\u0432\u0438\u0442 \u043a\u043b\u0430\u0441\u0441 \u0430\u043d\u043e\u043c\u0430\u043b\u0438\u0439, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 SQL-constraint \u043d\u0435 \u043e\u0442\u043b\u0430\u0432\u043b\u0438\u0432\u0430\u0435\u0442:<\/p>\n<div>\n<div class=\"table\">\n<table>\n<tbody>\n<tr>\n<th>\n<p align=\"left\">\u0421\u0442\u0440\u0430\u0442\u0435\u0433\u0438\u044f<\/p>\n<\/th>\n<th>\n<p align=\"left\">\u0427\u0442\u043e \u043b\u043e\u0432\u0438\u0442<\/p>\n<\/th>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><code>orphan-fk<\/code> + <code>tenant-scope<\/code><\/p>\n<\/td>\n<td>\n<p align=\"left\">\u0417\u0430\u043f\u0438\u0441\u0438 \u043d\u0430 \u043d\u0435\u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u0435 parents \u0438 cross-tenant FK leaks<\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><code>state-machine<\/code> + <code>date-invariants<\/code><\/p>\n<\/td>\n<td>\n<p align=\"left\"><code>status='finalized' AND finalized_at IS NULL<\/code>, <code>ends_at   &lt;= starts_at<\/code><\/p>\n<\/td>\n<\/tr>\n<tr>\n<td>\n<p align=\"left\"><code>logical-duplicates<\/code> + <code>soft-delete<\/code> + <code>audit-log<\/code><\/p>\n<\/td>\n<td>\n<p align=\"left\">\u0414\u0443\u0431\u043b\u0438\u043a\u0430\u0442\u044b \u0442\u0435\u043b\u0435\u0444\u043e\u043d\u0430\/\u043f\u0430\u0441\u043f\u043e\u0440\u0442\u0430 per tenant,   \u0430\u043d\u043e\u043d\u0438\u043c\u0438\u0437\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u044b\u0435 \u0441 PII, <code>data_category<\/code> \u0432\u043d\u0435 enum<\/p>\n<\/td>\n<\/tr>\n<\/tbody>\n<\/table>\n<\/div>\n<\/div>\n<p>\u0417\u0430\u043f\u0443\u0441\u043a\u0430\u0435\u0442\u0441\u044f \u043f\u0440\u043e\u0442\u0438\u0432 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0433\u043e restore prod-\u0434\u0430\u043c\u043f\u0430 (\u043d\u0435 \u043f\u0440\u043e\u0442\u0438\u0432 \u0441\u0430\u043c\u043e\u0433\u043e prod \u2014 long-running scan \u043d\u0430 \u0433\u043e\u0440\u044f\u0447\u0438\u0445 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0445 \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0435\u0442 writers). Exit code 1 \u043f\u0440\u0438 \u043d\u0430\u043b\u0438\u0447\u0438\u0438 findings \u2014 \u0433\u043e\u0434\u0438\u0442\u0441\u044f \u043a\u0430\u043a CI-gate \u043f\u0435\u0440\u0435\u0434 \u043c\u0430\u0436\u043e\u0440\u043d\u043e\u0439 \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0435\u0439.<\/p>\n<p><strong>2. Schema drift detection.**<\/strong> \u041f\u0435\u0440\u0435\u0434 \u043a\u0430\u0436\u0434\u044b\u043c \u0440\u0435\u043b\u0438\u0437\u043e\u043c \u2014 full cycle \u043d\u0430 \u0447\u0438\u0441\u0442\u043e\u0439 PG:<\/p>\n<pre><code class=\"bash\">```bashmake postgres-upDATABASE_URL=... alembic upgrade headDATABASE_URL=... alembic check  # \u0430\u0432\u0442\u043e\u0433\u0435\u043d diff \u043f\u0440\u043e\u0442\u0438\u0432 \u043c\u043e\u0434\u0435\u043b\u0435\u0439```<\/code><div class=\"code-explainer\"><a href=\"https:\/\/sourcecraft.dev\/\" class=\"tm-button code-explainer__link\" style=\"visibility: hidden;\"><img style=\"width:14px;height:14px;object-fit:cover;object-position:left;\"\/><\/a><\/div><\/pre>\n<p><code>alembic check<\/code> \u043d\u0435 \u0438\u0434\u0435\u0430\u043b\u0435\u043d: \u043d\u0435 \u0432\u0438\u0434\u0438\u0442 JSONB\u2192JSON \u0440\u0430\u0441\u0441\u0438\u043d\u0445\u0440\u043e\u043d \u043c\u0435\u0436\u0434\u0443 Postgres \u0438 SQLite, \u043d\u0435 \u0440\u0430\u0437\u043b\u0438\u0447\u0430\u0435\u0442 <code>Index(unique=True)<\/code> vs <code>UniqueConstraint<\/code>. \u041d\u043e \u0431\u0430\u0437\u043e\u0432\u044b\u0435 drift&#8217;\u044b \u043c\u0435\u0436\u0434\u0443 <a href=\"http:\/\/models.py\" rel=\"noopener noreferrer nofollow\"><code>models.py<\/code><\/a> \u0438 \u0440\u0435\u0430\u043b\u044c\u043d\u043e\u0439 \u0441\u0445\u0435\u043c\u043e\u0439 \u043b\u043e\u0432\u0438\u0442 \u043d\u0430\u0434\u0451\u0436\u043d\u043e. \u041d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0439 dev-\u0411\u0414 \u0441 \u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u043d\u044b\u043c \u043c\u0443\u0441\u043e\u0440\u043e\u043c \u044d\u0442\u043e\u0442 workflow \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u2014 drift \u043c\u0430\u0441\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u043c\u0438 \u043e\u0431\u044a\u0435\u043a\u0442\u0430\u043c\u0438.<\/p>\n<p><strong>\u041f\u044f\u0442\u044c \u0432\u044b\u0432\u043e\u0434\u043e\u0432<\/strong><\/p>\n<p>1. <strong>Multi-tenant \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043a\u043e\u043c\u043c\u0438\u0442\u0430 \u0447\u0435\u0440\u0435\u0437 <\/strong><code><strong>scoped_select<\/strong><\/code><strong> helper \u0438 <\/strong><code><strong>user.tenant_id<\/strong><\/code> \u2014 \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u0442\u0435\u043d\u0430\u043d\u0442 \u043e\u0434\u0438\u043d. \u0417\u0430\u0445\u0430\u0440\u0434\u043a\u043e\u0436\u0435\u043d\u043d\u044b\u0439 <code>config.TENANT_ID<\/code> \u2014 \u044d\u0442\u043e \u0442\u0435\u0445\u0434\u043e\u043b\u0433, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0432\u044b\u0441\u0442\u0440\u0435\u043b\u0438\u0432\u0430\u0435\u0442 \u043d\u0430 \u0432\u0442\u043e\u0440\u043e\u043c \u0442\u0435\u043d\u0430\u043d\u0442\u0435 \u0438 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 \u0440\u0435\u0432\u0438\u0437\u0438\u0438 \u043a\u0430\u0436\u0434\u043e\u0433\u043e endpoint&#8217;a.<\/p>\n<p>2. <strong>\u0417\u0430\u0449\u0438\u0442\u0443 \u043e\u0442 race-condition \u0431\u0440\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0439 \u0434\u0435\u043b\u0430\u0442\u044c DB-level \u0447\u0435\u0440\u0435\u0437 <\/strong><code><strong>EXCLUDE USING gist + tsrange<\/strong><\/code> \u2014 \u0434\u0435\u0448\u0435\u0432\u043b\u0435 \u0438 \u043d\u0430\u0434\u0451\u0436\u043d\u0435\u0435 application-level locking. \u0420\u0435\u0430\u043b\u044c\u043d\u044b\u0435 \u0446\u0438\u0444\u0440\u044b: 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u2192 1\u00d7200, 11\u00d7409, p95 31.69ms. \u041d\u0435 \u0442\u0440\u0435\u0431\u0443\u0435\u0442 Redis \u0438\u043b\u0438 distributed \u043a\u043e\u043e\u0440\u0434\u0438\u043d\u0430\u0446\u0438\u0438.<\/p>\n<p>3. <strong>152-\u0424\u0417-compliance \u2014 \u044d\u0442\u043e \u043f\u043e\u043b\u044f \u0438 helpers<\/strong>, \u043d\u0435 \u043c\u0430\u0440\u043a\u0435\u0442\u0438\u043d\u0433\u043e\u0432\u0430\u044f \u0433\u0430\u043b\u043e\u0447\u043a\u0430. <code>data_category<\/code> enum \u0432 <code>audit_logs<\/code> \u0441 \u0430\u0432\u0442\u043e\u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c \u043f\u043e <code>entity_type<\/code>, \u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u0430\u044f \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f PBKDF2 \u2192 argon2id \u0447\u0435\u0440\u0435\u0437 <code>password_needs_rehash<\/code>, \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u044b\u0439 <code>patient_consents<\/code> \u0441\u043e scoped \u0432\u0435\u0440\u0441\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c.<\/p>\n<p>4. <strong>DB integrity check \u043a\u0430\u043a \u043e\u043f\u0435\u0440\u0430\u0446\u0438\u043e\u043d\u043d\u044b\u0439 \u0430\u0440\u0442\u0435\u0444\u0430\u043a\u0442<\/strong>: \u0441\u0435\u043c\u044c \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439 \u0430\u043d\u043e\u043c\u0430\u043b\u0438\u0439, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 SQL constraints \u043d\u0435 \u043b\u043e\u0432\u044f\u0442. \u0417\u0430\u043f\u0443\u0441\u043a \u043f\u0440\u043e\u0442\u0438\u0432 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0433\u043e restore \u043f\u0440\u043e\u0434-\u0434\u0430\u043c\u043f\u0430 \u043f\u0435\u0440\u0435\u0434 \u043c\u0430\u0436\u043e\u0440\u043d\u044b\u043c \u0440\u0435\u043b\u0438\u0437\u043e\u043c \u2014 \u043d\u0435\u0434\u043e\u0440\u043e\u0433\u0430\u044f \u0441\u0442\u0440\u0430\u0445\u043e\u0432\u043a\u0430.<\/p>\n<p>5. <strong>Schema drift detection \u0432 CI<\/strong> \u2014 <code>alembic upgrade head + alembic check<\/code> \u043d\u0430 \u0447\u0438\u0441\u0442\u043e\u0439 PG. \u041d\u0430 dev-\u0411\u0414 \u0441 \u043d\u0430\u043a\u043e\u043f\u043b\u0435\u043d\u043d\u044b\u043c \u043c\u0443\u0441\u043e\u0440\u043e\u043c \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442: drift \u043c\u0430\u0441\u043a\u0438\u0440\u0443\u0435\u0442\u0441\u044f \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u044e\u0449\u0438\u043c\u0438 \u043e\u0431\u044a\u0435\u043a\u0442\u0430\u043c\u0438.<\/p>\n<\/div>\n<p>\u0441\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043e\u0440\u0438\u0433\u0438\u043d\u0430\u043b \u0441\u0442\u0430\u0442\u044c\u0438 <a href=\"https:\/\/habr.com\/ru\/articles\/1033488\/\">https:\/\/habr.com\/ru\/articles\/1033488\/<\/a><\/p>\n","protected":false},"excerpt":{"rendered":"<p>\u0421\u0430\u043c\u0430\u044f \u0434\u043e\u0440\u043e\u0433\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043c\u043e\u0435\u0433\u043e B2B SaaS \u0438\u043c\u0435\u043b\u0430 \u0440\u043e\u0432\u043d\u043e \u043e\u0434\u043d\u0443 \u0441\u0442\u0440\u043e\u0447\u043a\u0443&#171;`python  # app\/config.py  TENANT_ID = &#171;tenant-1&#187;  &#171;`\u041a\u043e\u0433\u0434\u0430 \u0443 \u043c\u0435\u043d\u044f \u0431\u044b\u043b \u043e\u0434\u0438\u043d \u0442\u0435\u043d\u0430\u043d\u0442, \u0432\u0441\u0451 \u0440\u0430\u0431\u043e\u0442\u0430\u043b\u043e \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e. \u041d\u0430 \u0432\u0442\u043e\u0440\u043e\u043c \u2014 \u043f\u043e\u043b\u043e\u0432\u0438\u043d\u0430 \u0430\u0434\u043c\u0438\u043d-\u0441\u0443\u0449\u043d\u043e\u0441\u0442\u0435\u0439 (\u0432\u0440\u0430\u0447\u0438, \u0443\u0441\u043b\u0443\u0433\u0438, \u043f\u0440\u0430\u0439\u0441-\u043b\u0438\u0441\u0442\u044b) \u043d\u0430\u0447\u0430\u043b\u0430 \u043f\u0440\u043e\u043f\u0430\u0434\u0430\u0442\u044c \u0438\u0437 \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441\u0430 \u043a\u043b\u0438\u0435\u043d\u0442\u0430. \u041d\u0435 \u00ab\u043d\u0435 \u0441\u043e\u0445\u0440\u0430\u043d\u044f\u0442\u044c\u0441\u044f\u00bb \u2014 \u0430 \u043f\u043e\u044f\u0432\u043b\u044f\u0442\u044c\u0441\u044f \u0432 \u0411\u0414 \u0441 \u0447\u0443\u0436\u0438\u043c tenant_id. \u042f \u043f\u043e\u043b\u0442\u043e\u0440\u0430 \u0434\u043d\u044f \u0441\u043c\u043e\u0442\u0440\u0435\u043b \u043d\u0430 \u044d\u0442\u0443 \u043c\u0438\u0441\u0442\u0438\u043a\u0443, \u043f\u0440\u0435\u0436\u0434\u0435 \u0447\u0435\u043c \u043f\u043e\u043d\u044f\u043b: 30 endpoint\u2019\u043e\u0432 \u0431\u0435\u0440\u0443\u0442 tenant_id \u0438\u0437 closure \u0438\u0437 config, \u0430 \u043d\u0435 \u0438\u0437 user.tenant_id. \u041e\u0447\u0435\u0432\u0438\u0434\u043d\u043e \u0432 \u0440\u0435\u0442\u0440\u043e\u0441\u043f\u0435\u043a\u0442\u0438\u0432\u0435. \u0421\u043e\u0432\u0435\u0440\u0448\u0435\u043d\u043d\u043e \u043d\u0435\u0432\u0438\u0434\u0438\u043c\u043e \u0432\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043f\u0438\u043b\u043e\u0442\u0430.\u042d\u0442\u043e\u0442 \u0440\u0430\u0437\u0431\u043e\u0440 \u2014 \u043f\u0440\u043e \u0442\u0440\u0438 \u0430\u0440\u0445\u0438\u0442\u0435\u043a\u0442\u0443\u0440\u043d\u044b\u0445 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u0434\u043b\u044f multi-tenant SaaS \u0432 \u0440\u0435\u0433\u0443\u043b\u0438\u0440\u0443\u0435\u043c\u043e\u0439 \u043e\u0442\u0440\u0430\u0441\u043b\u0438, \u043a\u043e\u0442\u043e\u0440\u044b\u0435 \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u044e\u0442\u0441\u044f \u0432 \u043f\u0435\u0440\u0432\u0443\u044e \u043d\u0435\u0434\u0435\u043b\u044e \u0438 \u043f\u043e\u0442\u043e\u043c \u0433\u043e\u0434\u0430\u043c\u0438 \u043b\u0438\u0431\u043e \u044d\u043a\u043e\u043d\u043e\u043c\u044f\u0442 \u043c\u0435\u0441\u044f\u0446\u044b \u0440\u0435\u0444\u0430\u043a\u0442\u043e\u0440\u0438\u043d\u0433\u0430, \u043b\u0438\u0431\u043e \u0442\u0438\u0445\u043e \u043a\u043e\u043f\u044f\u0442 \u0442\u0435\u0445\u0434\u043e\u043b\u0433. \u0421\u0442\u0435\u043a: Python 3.11 \/ FastAPI \/ SQLAlchemy 2.x \/ PostgreSQL 16. \u041a\u043e\u043d\u0442\u0435\u043a\u0441\u0442: B2B SaaS \u0434\u043b\u044f \u0447\u0430\u0441\u0442\u043d\u044b\u0445 \u043c\u0435\u0434\u0438\u0446\u0438\u043d\u0441\u043a\u0438\u0445 \u043a\u043b\u0438\u043d\u0438\u043a, 152-\u0424\u0417, real-time scheduling.TL;DR- Multi-tenancy \u0447\u0435\u0440\u0435\u0437 tenant_id column + helper scoped_select \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043a\u043e\u043c\u043c\u0438\u0442\u0430 \u2014 \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u0442\u0435\u043d\u0430\u043d\u0442 \u043e\u0434\u0438\u043d. \u041f\u0440\u0438\u0447\u0438\u043d\u0430 \u0432\u043d\u0443\u0442\u0440\u0438.- \u0417\u0430\u0449\u0438\u0442\u0430 \u043e\u0442 double-booking \u0447\u0435\u0440\u0435\u0437 PostgreSQL EXCLUDE USING gist + tsrange \u2014 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0435 \u0446\u0438\u0444\u0440\u044b: 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u043d\u0430 \u043e\u0434\u0438\u043d \u0441\u043b\u043e\u0442 \u2192 1\u00d7200, 11\u00d7409, \u0431\u0435\u0437 application-level lock&#8217;\u043e\u0432.- 152-\u0424\u0417 \u2014 \u044d\u0442\u043e \u043f\u043e\u043b\u044f \u0438 helpers: data_category enum \u0432 audit_logs \u0441 \u0430\u0432\u0442\u043e\u043a\u043b\u0430\u0441\u0441\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440\u043e\u043c, \u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u0430\u044f \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f \u0441 PBKDF2 \u043d\u0430 argon2id \u0431\u0435\u0437 forced reset, patient_consents \u0441\u043e scoped \u0432\u0435\u0440\u0441\u0438\u043e\u043d\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435\u043c.- \u041e\u0434\u0438\u043d \u0431\u0430\u0433 \u0441 \u0437\u0430\u0445\u0430\u0440\u0434\u043a\u043e\u0436\u0435\u043d\u043d\u044b\u043c TENANT_ID \u043f\u043e\u043a\u0430\u0437\u044b\u0432\u0430\u0435\u0442, \u043f\u043e\u0447\u0435\u043c\u0443 \u0432\u0441\u0435 \u0442\u0440\u0438 \u0440\u0435\u0448\u0435\u043d\u0438\u044f \u043d\u0443\u0436\u043d\u043e \u0437\u0430\u043a\u043b\u0430\u0434\u044b\u0432\u0430\u0442\u044c \u0441\u0440\u0430\u0437\u0443.- Operational tooling: read-only integrity check \u043d\u0430 \u0441\u0435\u043c\u044c \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439 \u0430\u043d\u043e\u043c\u0430\u043b\u0438\u0439 + schema drift detection \u0447\u0435\u0440\u0435\u0437 alembic check \u043d\u0430 \u0447\u0438\u0441\u0442\u043e\u0439 PG.\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u21161: multi-tenancy \u0447\u0435\u0440\u0435\u0437 tenant_id column\u0422\u0440\u0438 \u043a\u043b\u0430\u0441\u0441\u0438\u0447\u0435\u0441\u043a\u0438\u0445 \u043f\u043e\u0434\u0445\u043e\u0434\u0430 \u043a multi-tenancy \u0432 \u0440\u0435\u043b\u044f\u0446\u0438\u043e\u043d\u043d\u043e\u0439 \u0411\u0414:\u0421\u0442\u0440\u0430\u0442\u0435\u0433\u0438\u044f\u0418\u0437\u043e\u043b\u044f\u0446\u0438\u044fBackup\/migrations\u0421\u0442\u043e\u0438\u043c\u043e\u0441\u0442\u044c\u041a\u043e\u0433\u0434\u0430 \u043f\u043e\u0434\u0445\u043e\u0434\u0438\u0442Schema-per-tenant\u041d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 Postgres\u0421\u043b\u043e\u0436\u043d\u0430\u044f (N \u0441\u0445\u0435\u043c \u00d7 M \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u0439)\u0421\u0440\u0435\u0434\u043d\u044f\u044f5\u201350 enterprise \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432 \u0441 \u0440\u0430\u0437\u043d\u044b\u043c\u0438    SLADatabase-per-tenant\u0416\u0435\u043b\u0435\u0437\u043d\u0430\u044f\u041e\u0447\u0435\u043d\u044c \u0441\u043b\u043e\u0436\u043d\u0430\u044f\u0412\u044b\u0441\u043e\u043a\u0430\u044fCompliance-driven, \u226410 \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432Shared DB + tenant_id\u041d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f\u041f\u0440\u043e\u0441\u0442\u0430\u044f (\u043e\u0434\u043d\u0430 \u0441\u0445\u0435\u043c\u0430)\u041d\u0438\u0437\u043a\u0430\u044fSMB SaaS, \u0441\u043e\u0442\u043d\u0438\u2013\u0442\u044b\u0441\u044f\u0447\u0438   \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432\u042f \u0432\u044b\u0431\u0440\u0430\u043b \u0442\u0440\u0435\u0442\u0438\u0439 \u0432\u0430\u0440\u0438\u0430\u043d\u0442. \u041a\u0430\u0436\u0434\u0430\u044f \u0431\u0438\u0437\u043d\u0435\u0441-\u0442\u0430\u0431\u043b\u0438\u0446\u0430 \u0441\u043e\u0434\u0435\u0440\u0436\u0438\u0442 tenant_id TEXT NOT NULL \u0441 \u0438\u043d\u0434\u0435\u043a\u0441\u043e\u043c, \u0432\u0441\u0435 \u0437\u0430\u043f\u0440\u043e\u0441\u044b \u043e\u0431\u044f\u0437\u0430\u043d\u044b \u0444\u0438\u043b\u044c\u0442\u0440\u043e\u0432\u0430\u0442\u044c \u043f\u043e \u043d\u0435\u043c\u0443. \u0426\u0435\u043d\u0430 \u044d\u0442\u043e\u0439 \u043f\u0440\u043e\u0441\u0442\u043e\u0442\u044b \u2014 \u0435\u0434\u0438\u043d\u0441\u0442\u0432\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u0432 \u0444\u0438\u043b\u044c\u0442\u0440\u0435 \u043f\u0440\u0435\u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f \u0432 IDOR \u043d\u0430 \u0447\u0443\u0432\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u043c\u0435\u0436\u0434\u0443 \u0442\u0435\u043d\u0430\u043d\u0442\u0430\u043c\u0438.\u0427\u0442\u043e\u0431\u044b \u044d\u0442\u043e \u043d\u0435 \u0437\u0430\u0431\u044b\u0432\u0430\u0442\u044c, \u0432 \u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0435\u0441\u0442\u044c \u0442\u043e\u043d\u043a\u0438\u0439 helper:&#171;`python# app\/tenant_scope.pyfrom sqlalchemy import selectfrom sqlalchemy.sql import Selectdef scoped_select(model, user) -&gt; Select:    &#171;&#187;&#187;SELECT, \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u043d\u044b\u0439 \u0442\u0435\u043a\u0443\u0449\u0438\u043c \u0442\u0435\u043d\u0430\u043d\u0442\u043e\u043c.    \u041f\u0430\u0434\u0430\u0435\u0442 PermissionError, \u0435\u0441\u043b\u0438 \u0443 user \u043d\u0435\u0442 tenant_id \u2014 \u044d\u0442\u043e    \u043d\u0430\u043c\u0435\u0440\u0435\u043d\u043d\u043e: \u043b\u0443\u0447\u0448\u0435 500-\u043a\u0430 \u0432 \u043b\u043e\u0433\u0430\u0445, \u0447\u0435\u043c \u0442\u0438\u0445\u0430\u044f \u0443\u0442\u0435\u0447\u043a\u0430.    &#171;&#187;&#187;    if not user.tenant_id:        raise PermissionError(&#171;User has no tenant_id; refusing to query&#187;)    return select(model).where(model.tenant_id == user.tenant_id)def scoped_get(db, model, id_, user):    return db.scalar(        select(model).where(model.id == id_, model.tenant_id == user.tenant_id)    )&#171;`\u041f\u0440\u0438\u043d\u0446\u0438\u043f \u2014 \u043b\u0443\u0447\u0448\u0435 \u044f\u0432\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430, \u0447\u0435\u043c \u0442\u0438\u0445\u0430\u044f \u0443\u0442\u0435\u0447\u043a\u0430. \u0418 \u0432 code review \u043f\u0440\u0430\u0432\u0438\u043b\u043e \u043f\u0440\u043e\u0441\u0442\u043e\u0435: \u043b\u044e\u0431\u043e\u0439 select(BusinessModel) \u0431\u0435\u0437 scoped_select \u2014 \u0431\u043b\u043e\u043a\u0438\u0440\u0443\u044e\u0449\u0435\u0435 \u0437\u0430\u043c\u0435\u0447\u0430\u043d\u0438\u0435.Bug story \u0432 callout: \u043e\u0434\u043d\u0430 \u0441\u0442\u0440\u043e\u043a\u0430, \u0434\u0432\u0430 \u0434\u043d\u044f \u0434\u0435\u0431\u0430\u0433\u0430\u0412 \u043f\u0435\u0440\u0432\u043e\u0439 \u0432\u0435\u0440\u0441\u0438\u0438 \u044f \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043b closure tenant_id \u0438\u0437 config&#8217;\u0430 \u0432\u043e \u0432\u0441\u0435\u0445 write-handler&#8217;\u0430\u0445:&#171;`python&gt; @router.post(&#171;\/v1\/doctors&#187;)&gt; def create_doctor(payload, db, user):&gt;     item = Doctor(&gt;         id=f&#187;doctor-{uuid4()}&#187;,&gt;         tenant_id=tenant_id,           # \u2190 closure \u0438\u0437 config!&gt;         **payload.model_dump(),&gt;     )&gt; &#171;`Read-path \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043b scoped_select(Doctor, user) \u2014 \u043a\u043e\u0440\u0440\u0435\u043a\u0442\u043d\u043e. Write-path \u0431\u0430\u043a\u043e\u0432\u0430\u043b config.TENANT_ID. \u041f\u043e\u043a\u0430 \u0442\u0435\u043d\u0430\u043d\u0442 \u043e\u0434\u0438\u043d \u2014 \u0440\u0430\u0441\u0441\u0438\u043d\u0445\u0440\u043e\u043d \u043d\u0435\u0432\u0438\u0434\u0438\u043c. \u041a\u043e\u0433\u0434\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u043b\u0438 \u0432\u0442\u043e\u0440\u0443\u044e \u043a\u043b\u0438\u043d\u0438\u043a\u0443, \u0435\u0451 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440 \u0432\u0438\u0434\u0435\u043b \u0432 \u0441\u043f\u0438\u0441\u043a\u0435 \u0432\u0440\u0430\u0447\u0435\u0439 \u043f\u0443\u0441\u0442\u043e, \u0445\u043e\u0442\u044f \u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u043b \u0442\u0440\u0451\u0445. \u042d\u0442\u0438 \u0442\u0440\u043e\u0435 \u043b\u0435\u0436\u0430\u043b\u0438 \u0432 \u0411\u0414 \u0441 tenant_id=&#187;tenant-1&#8243;. \u0424\u0438\u043a\u0441: tenant_id=user.tenant_id \u0438 \u0440\u0435\u0433\u0440\u0435\u0441\u0441\u0438\u044f \u0432 E2E \u00ab\u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0442\u0435\u043d\u0430\u043d\u0442\u0430 B \u2192 \u0441\u043e\u0437\u0434\u0430\u0442\u044c \u0441\u0443\u0449\u043d\u043e\u0441\u0442\u044c \u043a\u0430\u043a user-B \u2192 \u0437\u0430\u043b\u043e\u0433\u0438\u043d\u0438\u0442\u044c\u0441\u044f \u043a\u0430\u043a user-A \u2192 \u0443\u0431\u0435\u0434\u0438\u0442\u044c\u0441\u044f, \u0447\u0442\u043e \u043d\u0435 \u0432\u0438\u0434\u0435\u043d\u00bb.\u0423\u0440\u043e\u043a: helper \u043d\u0443\u0436\u0435\u043d \u0441 \u043f\u0435\u0440\u0432\u043e\u0433\u043e \u043a\u043e\u043c\u043c\u0438\u0442\u0430, \u0434\u0430\u0436\u0435 \u0435\u0441\u043b\u0438 \u0442\u0435\u043d\u0430\u043d\u0442 \u043e\u0434\u0438\u043d. \u041d\u0430 \u0434\u0435\u0441\u044f\u0442\u043e\u043c \u0442\u0435\u043d\u0430\u043d\u0442\u0435 \u043f\u043e\u0437\u0434\u043d\u043e \u2014 \u043f\u0440\u0438\u0434\u0451\u0442\u0441\u044f \u0440\u0435\u0432\u0438\u0437\u043e\u0432\u0430\u0442\u044c \u043a\u0430\u0436\u0434\u044b\u0439 endpoint.\u0410 \u043f\u043e\u0447\u0435\u043c\u0443 \u043d\u0435 PostgreSQL Row-Level Security? \u042d\u0442\u043e \u043f\u0435\u0440\u0432\u044b\u0439 \u0432\u043e\u043f\u0440\u043e\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u043c\u043d\u0435 \u0437\u0430\u0434\u0430\u044e\u0442, \u0438 \u043e\u043d \u0441\u043f\u0440\u0430\u0432\u0435\u0434\u043b\u0438\u0432\u044b\u0439. RLS \u0434\u0430\u043b \u0431\u044b tenant isolation \u043d\u0430 \u0443\u0440\u043e\u0432\u043d\u0435 \u0411\u0414, \u0430 \u043d\u0435 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u2014 \u0442\u0435\u043e\u0440\u0435\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043d\u0430\u0434\u0451\u0436\u043d\u0435\u0435. \u041d\u0430 \u043f\u0440\u0430\u043a\u0442\u0438\u043a\u0435 RLS \u0442\u0440\u0435\u0431\u0443\u0435\u0442 SET app.tenant_id = &#8216;&#8230;&#8217; \u0432 \u043d\u0430\u0447\u0430\u043b\u0435 \u043a\u0430\u0436\u0434\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438, \u043b\u043e\u043c\u0430\u0435\u0442 stateless connection pooling (PgBouncer \u0432 transaction mode \u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0441\u043b\u043e\u0436\u043d\u0435\u0435 \u043d\u0430\u0441\u0442\u0440\u0430\u0438\u0432\u0430\u0442\u044c), \u0438 \u043f\u043b\u043e\u0445\u043e \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0441 migrations \u0438 admin-\u0437\u0430\u0434\u0430\u0447\u0430\u043c\u0438, \u0433\u0434\u0435 \u0445\u043e\u0447\u0435\u0442\u0441\u044f \u043e\u0431\u043e\u0439\u0442\u0438 \u043e\u0433\u0440\u0430\u043d\u0438\u0447\u0435\u043d\u0438\u0435 \u043b\u0435\u0433\u0430\u043b\u044c\u043d\u043e. \u0414\u043b\u044f solo-\u043f\u0440\u043e\u0435\u043a\u0442\u0430 \u043d\u0430 \u0440\u0430\u043d\u043d\u0435\u0439 \u0441\u0442\u0430\u0434\u0438\u0438 overhead RLS \u043d\u0435 \u043e\u043f\u0440\u0430\u0432\u0434\u0430\u043d \u2014 scoped_select + lint-\u043f\u0440\u0430\u0432\u0438\u043b\u043e \u0432 code review \u0434\u0430\u0451\u0442 95% \u0437\u0430\u0449\u0438\u0442\u044b \u043f\u0440\u0438 5% \u0441\u043b\u043e\u0436\u043d\u043e\u0441\u0442\u0438. \u041d\u0430 \u043f\u043e\u0437\u0434\u043d\u0435\u0439 \u0441\u0442\u0430\u0434\u0438\u0438 (\u0434\u0435\u0441\u044f\u0442\u043a\u0438 enterprise-\u0442\u0435\u043d\u0430\u043d\u0442\u043e\u0432 \u0441 compliance-\u0442\u0440\u0435\u0431\u043e\u0432\u0430\u043d\u0438\u044f\u043c\u0438) RLS \u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u0441\u044f \u0440\u0430\u0437\u0443\u043c\u043d\u044b\u043c \u0434\u043e\u043f\u043e\u043b\u043d\u0435\u043d\u0438\u0435\u043c, \u043d\u0435 \u0437\u0430\u043c\u0435\u043d\u043e\u0439.\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u21162: PostgreSQL EXCLUDE USING gist \u043f\u0440\u043e\u0442\u0438\u0432 double-booking\u0414\u0432\u0430 \u0430\u0434\u043c\u0438\u043d\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440\u0430 \u043e\u0434\u043d\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u043e \u0431\u0440\u043e\u043d\u0438\u0440\u0443\u044e\u0442 \u043e\u0434\u0438\u043d \u0438 \u0442\u043e\u0442 \u0436\u0435 \u0441\u043b\u043e\u0442 \u0443 \u043e\u0434\u043d\u043e\u0433\u043e \u0432\u0440\u0430\u0447\u0430. \u041e\u0431\u0430 \u0432\u0438\u0434\u044f\u0442 \u0441\u043b\u043e\u0442 \u0441\u0432\u043e\u0431\u043e\u0434\u043d\u044b\u043c. \u041e\u0431\u0430 \u0432\u044b\u0437\u044b\u0432\u0430\u044e\u0442 POST \/appointments. \u041e\u0431\u0430 \u043f\u043e\u043b\u0443\u0447\u0430\u044e\u0442 200 OK. \u0414\u0432\u043e\u0439\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c.\u0417\u0430\u0449\u0438\u0442\u0430 \u00ab\u0432 \u043a\u043e\u0434\u0435\u00bb (SELECT \u0434\u043b\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u2192 INSERT) \u043f\u043e\u0434 \u043f\u0430\u0440\u0430\u043b\u043b\u0435\u043b\u044c\u043d\u043e\u0439 \u043d\u0430\u0433\u0440\u0443\u0437\u043a\u043e\u0439 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442. \u041c\u0435\u0436\u0434\u0443 \u0434\u0432\u0443\u043c\u044f \u0437\u0430\u043f\u0440\u043e\u0441\u0430\u043c\u0438 \u0432 \u043e\u0434\u043d\u043e\u0439 \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u0438 \u0434\u0440\u0443\u0433\u0430\u044f \u0442\u0440\u0430\u043d\u0437\u0430\u043a\u0446\u0438\u044f \u0443\u0441\u043f\u0435\u0432\u0430\u0435\u0442 \u0432\u0441\u0442\u0430\u0432\u0438\u0442\u044c \u043f\u0435\u0440\u0435\u0441\u0435\u043a\u0430\u044e\u0449\u0438\u0439\u0441\u044f \u0441\u043b\u043e\u0442. \u042f \u044d\u0442\u043e \u0443\u0432\u0438\u0434\u0435\u043b \u043d\u0430 \u043f\u0435\u0440\u0432\u043e\u043c \u0436\u0435 load-\u0442\u0435\u0441\u0442\u0435.\u042d\u0432\u043e\u043b\u044e\u0446\u0438\u044f \u0437\u0430\u0449\u0438\u0442\u044b \u043d\u0430 \u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0448\u043b\u0430 \u0432 \u0442\u0440\u0438 \u0441\u0442\u0430\u0434\u0438\u0438. \u041f\u0440\u0438\u0432\u043e\u0436\u0443 \u0440\u0435\u0430\u043b\u044c\u043d\u044b\u0435 \u0446\u0438\u0444\u0440\u044b \u0438\u0437 benchmark&#8217;\u043e\u0432 \u043d\u0430 PostgreSQL 16, single-node:\u0421\u0442\u0430\u0434\u0438\u044f 1 \u2014 application-level check. 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u0437\u0430\u043f\u0440\u043e\u0441\u043e\u0432 \u043d\u0430 \u043e\u0434\u0438\u043d \u0441\u043b\u043e\u0442 \u2192 4\u00d7200, 8\u00d7409. \u0412\u0438\u0434\u0438\u043c\u044b \u0434\u0432\u043e\u0439\u043d\u044b\u0435 \u0437\u0430\u043f\u0438\u0441\u0438. create_unique_appointments p95 = 26ms.\u0421\u0442\u0430\u0434\u0438\u044f 2 \u2014 transactional advisory locks (pg_advisory_xact_lock \u043f\u043e (doctor_id, time_window) \u043f\u0435\u0440\u0435\u0434 INSERT&#8217;\u043e\u043c). 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u2192 1\u00d7200, 11\u00d7409. Race-condition \u0437\u0430\u043a\u0440\u044b\u0442, \u043d\u043e create_unique_appointments p95 \u043f\u043e\u0434\u043d\u044f\u043b\u0441\u044f \u0434\u043e 36.86ms \u0438\u0437-\u0437\u0430 serialization overhead.\u0421\u0442\u0430\u0434\u0438\u044f 3 \u2014 hard DB invariant \u0447\u0435\u0440\u0435\u0437 EXCLUDE USING gist. \u0422\u0435 \u0436\u0435 12 \u043a\u043e\u043d\u043a\u0443\u0440\u0435\u043d\u0442\u043d\u044b\u0445 \u2192 1\u00d7200, 11\u00d7409, create_unique_appointments p95 = 31.69ms, conflict-path p95 = 11.22ms. \u0417\u0430\u0449\u0438\u0442\u0430 \u043f\u0435\u0440\u0435\u043c\u0435\u0441\u0442\u0438\u043b\u0430\u0441\u044c \u0441 \u043f\u0440\u0438\u043b\u043e\u0436\u0435\u043d\u0438\u044f \u0432 schema, advisory lock \u0431\u043e\u043b\u044c\u0448\u0435 \u043d\u0435 \u043d\u0443\u0436\u0435\u043d.DDL constraint:&#171;sql&#8212; Alembic upgradeCREATE EXTENSION IF NOT EXISTS btree_gist;ALTER TABLE appointments ADD CONSTRAINT appointments_doctor_slot_excl  EXCLUDE USING gist (    doctor_id WITH =,    tsrange(starts_at, ends_at, &#8216;[)&#8217;) WITH &amp;&amp;  )  WHERE (status NOT IN (&#8216;cancelled&#8217;, &#8216;no_show&#8217;));&#171;`\u0427\u0442\u043e \u0437\u0434\u0435\u0441\u044c \u043f\u0440\u043e\u0438\u0441\u0445\u043e\u0434\u0438\u0442:- doctor_id WITH = \u2014 \u0434\u0432\u0430 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u0430 \u0440\u0430\u0441\u0441\u043c\u0430\u0442\u0440\u0438\u0432\u0430\u044e\u0442\u0441\u044f \u043a\u0430\u043a \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u0443\u044e\u0449\u0438\u0435, \u0442\u043e\u043b\u044c\u043a\u043e \u0435\u0441\u043b\u0438 \u0443 \u043d\u0438\u0445 \u043e\u0434\u0438\u043d\u0430\u043a\u043e\u0432\u044b\u0439 doctor_id.- tsrange(starts_at, ends_at, &#8216;[)&#8217;) \u2014 \u043f\u043e\u043b\u0443\u043e\u0442\u043a\u0440\u044b\u0442\u044b\u0439 \u0438\u043d\u0442\u0435\u0440\u0432\u0430\u043b; \u0437\u0430\u043f\u0438\u0441\u044c 10:00\u201310:30 \u0438 10:30\u201311:00 \u043d\u0435 \u043f\u0435\u0440\u0435\u0441\u0435\u043a\u0430\u044e\u0442\u0441\u044f.- &amp;&amp; \u2014 \u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u043f\u0435\u0440\u0435\u0441\u0435\u0447\u0435\u043d\u0438\u044f \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u043e\u0432.- WHERE (status NOT IN (&#8216;cancelled&#8217;, &#8216;no_show&#8217;)) \u2014 \u043e\u0442\u043c\u0435\u043d\u0451\u043d\u043d\u044b\u0435 \u0438 \u043d\u0435\u044f\u0432\u043a\u0438 \u0438\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u044b, \u043f\u0430\u0446\u0438\u0435\u043d\u0442 \u043f\u043e\u0441\u043b\u0435 \u043e\u0442\u043c\u0435\u043d\u044b \u043c\u043e\u0436\u0435\u0442 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0438\u0441\u0430\u0442\u044c\u0441\u044f \u0432 \u0442\u043e\u0442 \u0436\u0435 \u0441\u043b\u043e\u0442.btree_gist \u043d\u0443\u0436\u0435\u043d, \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e = \u0434\u043b\u044f \u0441\u043a\u0430\u043b\u044f\u0440\u043d\u043e\u0433\u043e doctor_id \u0447\u0435\u0440\u0435\u0437 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442\u043d\u044b\u0439 gist-\u043e\u043f\u0435\u0440\u0430\u0442\u043e\u0440 \u043d\u0435 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442; \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u044f\u0435\u0442 btree-\u0441\u0435\u043c\u0430\u043d\u0442\u0438\u043a\u0443 \u0432\u043d\u0443\u0442\u0440\u044c gist-\u0438\u043d\u0434\u0435\u043a\u0441\u0430.\u041c\u0430\u043f\u043f\u0438\u043d\u0433 \u043e\u0448\u0438\u0431\u043a\u0438 \u0432 HTTP \u0432 FastAPI:&#171;pythonfrom psycopg.errors import ExclusionViolationtry:    db.add(appointment)    db.flush()except IntegrityError as exc:    if isinstance(exc.orig, ExclusionViolation):        raise HTTPException(            status_code=409,            detail={                &#171;code&#187;: &#171;APPOINTMENT_SLOT_CONFLICT&#187;,                &#171;message&#187;: &#171;\u0421\u043b\u043e\u0442 \u0437\u0430\u043d\u044f\u0442 \u0434\u0440\u0443\u0433\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e&#187;,            },        )    raise&#171;`tsrange \u0438\u043b\u0438 tstzrange? \u042f \u0432\u044b\u0431\u0440\u0430\u043b tsrange (naive timestamp \u0431\u0435\u0437 timezone), \u043f\u043e\u0442\u043e\u043c\u0443 \u0447\u0442\u043e \u0432 \u044d\u0442\u043e\u043c \u043f\u0440\u043e\u0435\u043a\u0442\u0435 \u0432\u0441\u0435 timestamp&#8217;\u044b \u0445\u0440\u0430\u043d\u044f\u0442\u0441\u044f \u0432 UTC, \u0430 \u043a\u043e\u043d\u0432\u0435\u0440\u0441\u0438\u044f \u0432 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u043e\u0435 \u0432\u0440\u0435\u043c\u044f \u043a\u043b\u0438\u043d\u0438\u043a\u0438 \u0434\u0435\u043b\u0430\u0435\u0442\u0441\u044f \u043d\u0430 app-\u0443\u0440\u043e\u0432\u043d\u0435 \u0447\u0435\u0440\u0435\u0437 tenant.timezone. tstzrange \u0440\u0430\u0431\u043e\u0442\u0430\u043b \u0431\u044b, \u043d\u043e \u0434\u043e\u0431\u0430\u0432\u0438\u043b \u0431\u044b \u043b\u0438\u0448\u043d\u0438\u0439 \u0441\u043b\u043e\u0439 \u043d\u0435\u044f\u0432\u043d\u044b\u0445 \u043a\u043e\u043d\u0432\u0435\u0440\u0441\u0438\u0439 \u0438 \u043f\u043e\u0442\u0435\u043d\u0446\u0438\u0430\u043b\u044c\u043d\u044b\u0445 \u0431\u0430\u0433\u043e\u0432 \u0441 DST. \u0415\u0441\u043b\u0438 \u0443 \u0432\u0430\u0441 \u043f\u043e\u043b\u0438\u0442\u0438\u043a\u0430 \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u00abtimestamp with timezone \u0432\u0435\u0437\u0434\u0435\u00bb \u2014 \u0431\u0435\u0440\u0438\u0442\u0435 tstzrange, \u0440\u0430\u0437\u043d\u0438\u0446\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u0432 \u0442\u043e\u043c, \u043a\u0430\u043a Postgres \u0432\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u0435 \u0442\u0440\u0430\u043a\u0442\u0443\u0435\u0442 \u0434\u0438\u0430\u043f\u0430\u0437\u043e\u043d\u044b.\u041f\u043e\u0434\u0432\u043e\u0434\u043d\u044b\u0435 \u043a\u0430\u043c\u043d\u0438:- EXCLUDE \u0441\u043e\u0437\u0434\u0430\u0451\u0442 gist-\u0438\u043d\u0434\u0435\u043a\u0441, \u043a\u043e\u0442\u043e\u0440\u044b\u0439 \u0440\u0430\u0441\u0442\u0451\u0442 \u0431\u044b\u0441\u0442\u0440\u0435\u0435 B-tree \u043d\u0430 \u0431\u043e\u043b\u044c\u0448\u0438\u0445 \u043e\u0431\u044a\u0451\u043c\u0430\u0445. \u041f\u043e [\u0441\u0442\u0430\u0442\u044c\u0435 \u043d\u0430 \u0425\u0430\u0431\u0440\u0435 \u043f\u0440\u043e btree_gist benchmark](https:\/\/habr.com\/ru\/articles\/820455\/) \u2014 \u0434\u0435\u0433\u0440\u0430\u0434\u0430\u0446\u0438\u044f \u0437\u0430\u043f\u0438\u0441\u0438 \u0434\u043e 2\u00d7 \u0438 \u0447\u0442\u0435\u043d\u0438\u044f \u0434\u043e 20% \u043d\u0430 \u0433\u043e\u0440\u044f\u0447\u0438\u0445 \u0442\u0430\u0431\u043b\u0438\u0446\u0430\u0445. \u0414\u043b\u044f \u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0437\u0430\u043f\u0438\u0441\u0435\u0439 \u043f\u0430\u0446\u0438\u0435\u043d\u0442\u043e\u0432 \u044d\u0442\u043e \u043e\u043a\u0435\u0439 (\u043a\u0430\u0440\u0434\u0438\u043d\u0430\u043b\u044c\u043d\u043e\u0441\u0442\u044c \u043d\u0435\u0432\u044b\u0441\u043e\u043a\u0430\u044f), \u0434\u043b\u044f \u0442\u0430\u0431\u043b\u0438\u0446\u044b \u0441\u043e\u0431\u044b\u0442\u0438\u0439 \u0441 \u043c\u0438\u043b\u043b\u0438\u043e\u043d\u0430\u043c\u0438 \u0441\u0442\u0440\u043e\u043a \u2014 \u0437\u0430\u0434\u0443\u043c\u0430\u0439\u0442\u0435\u0441\u044c.- WHERE-\u043a\u043b\u0430\u0443\u0437\u0430 partial constraint \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u0442\u043e\u043b\u044c\u043a\u043e \u043f\u0440\u0438 INSERT\/DELETE. Cancellation \u0434\u043e\u043b\u0436\u043d\u0430 \u0431\u044b\u0442\u044c UPDATE status=&#8217;cancelled&#8217;, \u043d\u0435 DELETE \u2014 \u0438\u043d\u0430\u0447\u0435 \u0437\u0430\u043f\u0438\u0441\u044c \u00ab\u0438\u0441\u0447\u0435\u0437\u0430\u0435\u0442\u00bb, \u0438 \u0441\u043b\u043e\u0442 \u0447\u0435\u0440\u0435\u0437 \u0441\u0435\u043a\u0443\u043d\u0434\u0443 \u0437\u0430\u043d\u0438\u043c\u0430\u0435\u0442 \u043a\u0442\u043e-\u0442\u043e \u0435\u0449\u0451, \u0430 \u043e\u0442\u043c\u0435\u043d\u0451\u043d\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u043d\u0435 \u0432\u043e\u0441\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c.- \u0412 \u0442\u0435\u0441\u0442\u0430\u0445 \u043d\u0443\u0436\u0435\u043d \u0442\u043e\u0442 \u0436\u0435 \u0434\u0438\u0430\u043b\u0435\u043a\u0442 (PG, \u043d\u0435 SQLite). SQLite \u043f\u0440\u0438\u043d\u0438\u043c\u0430\u0435\u0442 DDL \u0431\u0435\u0437 \u043e\u0448\u0438\u0431\u043e\u043a, \u043d\u043e \u043d\u0438\u043a\u0430\u043a\u0438\u0445 exclusion constraint \u043d\u0435 \u0441\u043e\u0437\u0434\u0430\u0451\u0442 \u2014 \u0431\u0430\u0433 \u0432 \u0442\u0435\u0441\u0442\u0430\u0445 \u043d\u0435 \u043e\u0442\u043b\u043e\u0432\u0438\u0442\u044c.\u0427\u0442\u043e \u044f \u043f\u043e\u043b\u0443\u0447\u0438\u043b \u0432 \u0438\u0442\u043e\u0433\u0435: \u043f\u0440\u0438 \u0440\u043e\u0441\u0442\u0435 c 1 \u0434\u043e 10 \u043a\u043b\u0438\u043d\u0438\u043a \u0432 \u043f\u0440\u043e\u0434\u0435 \u043d\u0438 \u043e\u0434\u043d\u043e\u0439 \u0434\u0432\u043e\u0439\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438. \u041d\u0435 \u043d\u0443\u0436\u0435\u043d Redis, \u043d\u0435 \u043d\u0443\u0436\u0435\u043d distributed lock, \u043d\u0435 \u043d\u0443\u0436\u043d\u043e application-level retry. Pos\u00adtgreSQL \u0434\u0435\u043b\u0430\u0435\u0442 \u0432\u0441\u044e \u0440\u0430\u0431\u043e\u0442\u0443.\u0420\u0435\u0448\u0435\u043d\u0438\u0435 \u21163: 152-\u0424\u0417 \u043a\u0430\u043a \u043a\u043e\u0434\u0414\u043b\u044f \u043c\u0435\u0434\u0438\u0446\u0438\u043d\u0441\u043a\u0438\u0445 \u0434\u0430\u043d\u043d\u044b\u0445 \u0432 \u0420\u0424 152-\u0424\u0417 \u0442\u0440\u0435\u0431\u0443\u0435\u0442: \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f \u0432 \u0440\u043e\u0441\u0441\u0438\u0439\u0441\u043a\u0438\u0445 \u0426\u041e\u0414\u0430\u0445, \u0437\u0430\u0449\u0438\u0442\u044b \u0434\u0430\u043d\u043d\u044b\u0445 \u043f\u0440\u0438 \u043f\u0435\u0440\u0435\u0434\u0430\u0447\u0435 \u0438 \u0445\u0440\u0430\u043d\u0435\u043d\u0438\u0438, \u0436\u0443\u0440\u043d\u0430\u043b\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043a \u041f\u0414\u043d, \u043e\u0442\u0434\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u0441\u043e\u0433\u043b\u0430\u0441\u0438\u044f \u043d\u0430 \u043e\u0431\u0440\u0430\u0431\u043e\u0442\u043a\u0443 \u0441\u043f\u0435\u0446\u0438\u0430\u043b\u044c\u043d\u044b\u0445 \u043a\u0430\u0442\u0435\u0433\u043e\u0440\u0438\u0439, \u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e\u0441\u0442\u0438 \u0443\u0434\u0430\u043b\u0435\u043d\u0438\u044f\/\u0430\u043d\u043e\u043d\u0438\u043c\u0438\u0437\u0430\u0446\u0438\u0438 \u043f\u043e \u0437\u0430\u043f\u0440\u043e\u0441\u0443. \u0427\u0442\u043e \u0438\u0437 \u044d\u0442\u043e\u0433\u043e \u043f\u0440\u0435\u0432\u0440\u0430\u0449\u0430\u0435\u0442\u0441\u044f \u0432 \u043a\u043e\u0434:\u0428\u0438\u0444\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u043f\u0430\u0440\u043e\u043b\u0435\u0439: \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f PBKDF2 \u2192 argon2id \u0431\u0435\u0437 forced reset\u0418\u0437\u043d\u0430\u0447\u0430\u043b\u044c\u043d\u043e \u043f\u0430\u0440\u043e\u043b\u0438 \u0445\u0435\u0448\u0438\u043b\u0438\u0441\u044c \u0447\u0435\u0440\u0435\u0437 PBKDF2-HMAC-SHA256 \u0441\u043e 120 000 \u0438\u0442\u0435\u0440\u0430\u0446\u0438\u0439 \u2014 \u0441\u0442\u0430\u043d\u0434\u0430\u0440\u0442 OWASP \u0434\u043e 2023 \u0433\u043e\u0434\u0430. \u0421\u043e\u0432\u0440\u0435\u043c\u0435\u043d\u043d\u0430\u044f \u0440\u0435\u043a\u043e\u043c\u0435\u043d\u0434\u0430\u0446\u0438\u044f OWASP\/NIST \u2014 argon2id (memory-hard, \u0443\u0441\u0442\u043e\u0439\u0447\u0438\u0432\u0435\u0435 \u043a GPU-\u0430\u0442\u0430\u043a\u0430\u043c). \u0420\u0435\u0437\u043a\u0430\u044f \u043c\u0438\u0433\u0440\u0430\u0446\u0438\u044f \u0447\u0435\u0440\u0435\u0437 forced password reset \u043d\u0435\u0434\u043e\u043f\u0443\u0441\u0442\u0438\u043c\u0430 \u0432 B2B (\u0434\u0435\u0441\u044f\u0442\u043a\u0438 \u0441\u043e\u0442\u0440\u0443\u0434\u043d\u0438\u043a\u043e\u0432 \u043a\u043b\u0438\u043d\u0438\u043a\u0438, \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0442\u043e\u0440 \u043d\u0435 \u0441\u043c\u043e\u0436\u0435\u0442 \u0437\u0430\u043b\u043e\u0433\u0438\u043d\u0438\u0442\u044c\u0441\u044f \u0443\u0442\u0440\u043e\u043c \u043f\u043e\u043d\u0435\u0434\u0435\u043b\u044c\u043d\u0438\u043a\u0430), \u043f\u043e\u044d\u0442\u043e\u043c\u0443 \u0440\u0430\u0431\u043e\u0442\u0430\u0435\u0442 \u043f\u0440\u043e\u0437\u0440\u0430\u0447\u043d\u0430\u044f \u0441\u0445\u0435\u043c\u0430: \u043d\u043e\u0432\u044b\u0435 \u043f\u0430\u0440\u043e\u043b\u0438 \u043f\u0438\u0448\u0443\u0442\u0441\u044f \u043a\u0430\u043a argon2id, \u0441\u0442\u0430\u0440\u044b\u0435 \u0432\u0435\u0440\u0438\u0444\u0438\u0446\u0438\u0440\u0443\u044e\u0442\u0441\u044f \u043f\u043e \u0441\u0432\u043e\u0435\u043c\u0443 \u0444\u043e\u0440\u043c\u0430\u0442\u0443, \u043f\u0440\u0438 \u0443\u0441\u043f\u0435\u0448\u043d\u043e\u043c \u043b\u043e\u0433\u0438\u043d\u0435 \u043c\u043e\u043b\u0447\u0430 \u043f\u0435\u0440\u0435\u0445\u0435\u0448\u0438\u0440\u0443\u044e\u0442\u0441\u044f \u0432 argon2id.\u041a\u043b\u044e\u0447 \u2014 \u0434\u0435\u0442\u0435\u043a\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u0438\u0435 \u0444\u043e\u0440\u043c\u0430\u0442\u0430 \u043f\u043e \u043f\u0440\u0435\u0444\u0438\u043a\u0441\u0443 \u0445\u0435\u0448\u0430:&#171;`pythonimport hashlibimport secretsfrom argon2 import PasswordHasherfrom argon2 import exceptions as argon2_exceptionsPASSWORD_ITERATIONS = 120_000  # legacy PBKDF2 \u0434\u043b\u044f backward-compatPASSWORD_PREFIX = &#171;pbkdf2_sha256&#8243;ARGON2_PREFIX = &#171;$argon2&#8243;PASSWORD_HASHER = PasswordHasher()  # argon2id \u0441 \u0434\u0435\u0444\u043e\u043b\u0442\u043d\u044b\u043c\u0438 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0430\u043c\u0438def hash_password(password: str) -&gt; str:    &#171;&#187;&#187;\u0412\u0441\u0435&#8230;<\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"closed","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[],"tags":[],"class_list":["post-479210","post","type-post","status-publish","format-standard","hentry"],"_links":{"self":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/479210","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=479210"}],"version-history":[{"count":0,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=\/wp\/v2\/posts\/479210\/revisions"}],"wp:attachment":[{"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=479210"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=479210"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/savepearlharbor.com\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=479210"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}