Limit access to database from the same series as the server

The last series to which the database was updated is stored in
ir.configuration.
With this information, the list of databases is filtered for the same series.
Remote access to a database is restricted to databases available in the list.

Closes #13791
This commit is contained in:
Cédric Krier committed 2025-01-06 15:54:02 +01:00
1 parent 1168597b27
commit 1edefbeae5
12 files changed
+100 -49

No files matched your search

+1
View File
@@ -1,3 +1,4 @@
* Limit access to database from the same series
* Fill wizard state view instance with default value
* Replace passlib by pwdlib
* Add size limit for RPC arguments
+3 -1
View File
@@ -59,11 +59,13 @@ Database
Initialize the database schema.
.. method:: Database.test([hostname])
.. method:: Database.test([hostname[, series]])
Return if the database is a Tryton database.
If ``hostname`` is set, it checks also if it has the same configured
hostname.
If ``series`` is ``True``, it checks also if it has the same series from the
last update.
.. method:: Database.nextid(connection, table[, count])
+1
View File
@@ -16,6 +16,7 @@ except ImportError:
requests_utils = None
__version__ = "7.5.0"
__series__ = '.'.join(__version__.split('.')[:2])
os.environ.setdefault(
'TRYTOND_APPNAME',
+4 -1
View File
@@ -8,7 +8,7 @@ from getpass import getpass
from sql import Literal, Table
from trytond import backend
from trytond import __series__, backend
from trytond.config import config
from trytond.pool import Pool
from trytond.sendmail import send_test_email
@@ -129,6 +129,9 @@ def run(options):
User = pool.get('res.user')
Configuration = pool.get('ir.configuration')
configuration = Configuration(1)
if init[db_name]:
configuration.series = __series__
with inactive_records():
admin, = User.search([('login', '=', 'admin')])
+1 -1
View File
@@ -51,7 +51,7 @@ class DatabaseInterface(object):
def init(self):
raise NotImplementedError
def test(self, hostname=None):
def test(self, hostname=None, series=False):
'''
Test if it is a Tryton database.
'''
+22 -18
View File
@@ -27,8 +27,8 @@ from psycopg2 import DataError as DatabaseDataError
from psycopg2 import IntegrityError as DatabaseIntegrityError
from psycopg2 import InterfaceError
from psycopg2 import OperationalError as DatabaseOperationalError
from psycopg2 import ProgrammingError
from psycopg2.errors import QueryCanceled as DatabaseTimeoutError
from psycopg2.errors import UndefinedColumn
from psycopg2.extras import register_default_json, register_default_jsonb
from sql import Cast, Flavor, For, Literal, Table
from sql.aggregate import Count
@@ -36,6 +36,7 @@ from sql.conditionals import Coalesce
from sql.functions import Function
from sql.operators import BinaryOperator, Concat
from trytond import __series__
from trytond.backend.database import DatabaseInterface, SQLType
from trytond.config import config, parse_uri
from trytond.sql.operators import RangeOperator
@@ -345,18 +346,19 @@ class Database(DatabaseInterface):
if res and abs(timestamp - now) < timeout:
return res
res = []
connection = self.get_connection()
try:
cursor = connection.cursor()
cursor.execute('SELECT datname FROM pg_database '
'WHERE datistemplate = false ORDER BY datname')
res = []
for db_name, in cursor:
try:
conn = connect(**self._connection_params(db_name))
try:
with conn:
if self._test(conn, hostname=hostname):
if self._test(
conn, hostname=hostname, series=True):
res.append(db_name)
finally:
conn.close()
@@ -401,36 +403,38 @@ class Database(DatabaseInterface):
finally:
self.put_connection(connection)
def test(self, hostname=None):
def test(self, hostname=None, series=False):
try:
connection = self.get_connection()
except Exception:
logger.debug('Test failed for "%s"', self.name, exc_info=True)
return False
try:
return self._test(connection, hostname=hostname)
return self._test(connection, hostname=hostname, series=series)
finally:
self.put_connection(connection, close=True)
@classmethod
def _test(cls, connection, hostname=None):
def _test(cls, connection, hostname=None, series=False):
cursor = connection.cursor()
tables = ('ir_model', 'ir_model_field', 'ir_ui_view', 'ir_ui_menu',
'res_user', 'res_group', 'ir_module', 'ir_module_dependency',
'ir_translation', 'ir_lang', 'ir_configuration')
cursor.execute('SELECT table_name FROM information_schema.tables '
'WHERE table_name IN %s', (tables,))
if len(cursor.fetchall()) != len(tables):
cursor.execute(
'SELECT table_name FROM information_schema.tables '
'WHERE table_name = %s', ('ir_configuration',))
if not cursor.rowcount:
return False
if hostname:
try:
cursor.execute(
'SELECT hostname FROM ir_configuration')
try:
if series:
cursor.execute('SELECT series FROM ir_configuration')
config_series = {s for s, in cursor if s}
if config_series and __series__ not in config_series:
return False
if hostname:
cursor.execute('SELECT hostname FROM ir_configuration')
hostnames = {h for h, in cursor if h}
if hostnames and hostname not in hostnames:
return False
except ProgrammingError:
pass
except UndefinedColumn:
return False
return True
def nextid(self, connection, table, count=1):
@@ -1,6 +1,7 @@
CREATE TABLE ir_configuration (
id INTEGER GENERATED BY DEFAULT AS IDENTITY NOT NULL
CONSTRAINT ir_configuration_id_positive CHECK(id >= 0),
series VARCHAR,
language VARCHAR,
hostname VARCHAR,
PRIMARY KEY(id)
+44 -27
View File
@@ -7,6 +7,7 @@ import os
import random
import sqlite3 as sqlite
import threading
import time
import urllib.parse
import warnings
from decimal import Decimal
@@ -23,6 +24,7 @@ from sql.functions import (
Substring, Trim)
from sql.operators import Equal
from trytond import __series__
from trytond.backend.database import DatabaseInterface, SQLType
from trytond.config import config, parse_uri
from trytond.tools import safe_join
@@ -438,6 +440,8 @@ class Database(DatabaseInterface):
_local = threading.local()
_conn = None
_list_cache = {}
_list_cache_timestamp = {}
flavor = Flavor(
paramstyle='qmark', function_mapping=MAPPING, null_ordering=False,
max_limit=-1)
@@ -599,24 +603,33 @@ class Database(DatabaseInterface):
with sqlite.connect(path) as conn:
cursor = conn.cursor()
cursor.close()
cls._list_cache.clear()
@classmethod
def drop(cls, connection, database_name):
if database_name == ':memory:':
cls._local.memory_database._conn = None
return
if os.sep in database_name:
return
file = os.path.join(
config.get('database', 'path'), database_name + '.sqlite')
os.remove(file)
for suffix in ['-shm', '-wal']:
try:
os.remove(file + suffix)
except FileNotFoundError:
pass
if database_name == ':memory:':
cls._local.memory_database._conn = None
else:
file = os.path.join(
config.get('database', 'path'), database_name + '.sqlite')
os.remove(file)
for suffix in ['-shm', '-wal']:
try:
os.remove(file + suffix)
except FileNotFoundError:
pass
cls._list_cache.clear()
def list(self, hostname=None):
now = time.time()
timeout = config.getint('session', 'timeout')
res = self.__class__._list_cache.get(hostname)
timestamp = self.__class__._list_cache_timestamp.get(hostname, now)
if res and abs(timestamp - now) < timeout:
return res
res = []
listdir = [':memory:']
try:
@@ -635,9 +648,12 @@ class Database(DatabaseInterface):
logger.debug(
'Test failed for "%s"', db_name, exc_info=True)
continue
if database.test(hostname=hostname):
if database.test(hostname=hostname, series=True):
res.append(db_name)
database.close()
self.__class__._list_cache[hostname] = res
self.__class__._list_cache_timestamp[hostname] = now
return res
def init(self):
@@ -673,28 +689,29 @@ class Database(DatabaseInterface):
cursor.execute(*insert)
conn.commit()
def test(self, hostname=None):
Flavor.set(self.flavor)
tables = ['ir_model', 'ir_model_field', 'ir_ui_view', 'ir_ui_menu',
'res_user', 'res_group', 'ir_module', 'ir_module_dependency',
'ir_translation', 'ir_lang', 'ir_configuration']
sqlite_master = Table('sqlite_master')
select = sqlite_master.select(sqlite_master.name)
select.where = sqlite_master.type == 'table'
select.where &= sqlite_master.name.in_(tables)
def test(self, hostname=None, series=False):
with self._conn as conn:
cursor = conn.cursor()
try:
cursor.execute(*select)
cursor.execute(
'SELECT name FROM sqlite_master '
'WHERE type = ? AND name = ?',
('table', 'ir_configuration'))
except Exception:
return False
if len(cursor.fetchall()) != len(tables):
if not cursor.fetchall():
return False
if hostname:
configuration = Table('ir_configuration')
if series:
try:
cursor.execute(*configuration.select(
configuration.hostname))
cursor.execute('SELECT series FROM ir_configuration')
except Exception:
return False
config_series = {s for s, in cursor if s}
if config_series and __series__ not in config_series:
return False
if hostname:
try:
cursor.execute('SELECT hostname FROM ir_configuration')
except Exception:
return False
hostnames = {h for h, in cursor if h}
+1
View File
@@ -7,6 +7,7 @@ from trytond.model import ModelSingleton, ModelSQL, fields
class Configuration(ModelSingleton, ModelSQL):
__name__ = 'ir.configuration'
series = fields.Char("Series")
language = fields.Char('language')
hostname = fields.Char("Hostname", strip=False)
_get_language_cache = Cache('ir_configuration.get_language')
+4
View File
@@ -13,6 +13,7 @@ from sql.functions import CurrentTimestamp
import trytond.convert as convert
import trytond.tools as tools
from trytond import __series__
from trytond.config import config
from trytond.const import MODULES_GROUP
from trytond.exceptions import MissingDependenciesException
@@ -22,6 +23,7 @@ logger = logging.getLogger(__name__)
ir_module = Table('ir_module')
ir_model_data = Table('ir_model_data')
ir_configuration = Table('ir_configuration')
MODULES_PATH = os.path.abspath(os.path.dirname(__file__))
@@ -288,6 +290,8 @@ def load_module_graph(graph, pool, update=None, lang=None, indexes=None):
model._update_sql_indexes(concurrently=concurrently)
if update:
cursor.execute(*ir_configuration.update(
[ir_configuration.series], [__series__]))
if indexes or indexes is None:
create_indexes(concurrently=False)
else:
+7 -1
View File
@@ -19,7 +19,7 @@ from werkzeug.wrappers import Request as _Request
from werkzeug.wrappers import Response
from trytond import backend, security
from trytond.config import config
from trytond.config import config, get_hostname
from trytond.exceptions import RateLimitException, UserError, UserWarning
from trytond.pool import Pool
from trytond.tools import cached_property
@@ -191,6 +191,12 @@ def with_pool(func):
@wraps(func)
def wrapper(request, database_name, *args, **kwargs):
database_list = Pool.database_list()
if database_name not in database_list:
with Transaction().start(None, 0, readonly=True) as transaction:
hostname = get_hostname(request.host)
db_list = transaction.database.list(hostname=hostname)
if database_name not in db_list:
abort(HTTPStatus.NOT_FOUND)
pool = Pool(database_name)
if database_name not in database_list:
with Transaction().start(database_name, 0, readonly=True):
+11
View File
@@ -5,6 +5,13 @@ import logging
import random
import time
try:
from http import HTTPStatus
except ImportError:
from http import client as HTTPStatus
from werkzeug.exceptions import abort
from trytond import backend
from trytond.config import config
from trytond.exceptions import LoginException, RateLimitException
@@ -16,6 +23,10 @@ logger = logging.getLogger(__name__)
def _get_pool(dbname):
database_list = Pool.database_list()
if dbname not in database_list:
db_list = Transaction().database.list()
if dbname not in db_list:
abort(HTTPStatus.NOT_FOUND)
pool = Pool(dbname)
if dbname not in database_list:
pool.init()