diff --git a/trytond/CHANGELOG b/trytond/CHANGELOG index 0501425744..698b89cde5 100644 --- a/trytond/CHANGELOG +++ b/trytond/CHANGELOG @@ -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 diff --git a/trytond/doc/ref/backend.rst b/trytond/doc/ref/backend.rst index fba94c6cf6..4a541cda3f 100644 --- a/trytond/doc/ref/backend.rst +++ b/trytond/doc/ref/backend.rst @@ -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]) diff --git a/trytond/trytond/__init__.py b/trytond/trytond/__init__.py index c327a0fe9f..f5e06d3a50 100644 --- a/trytond/trytond/__init__.py +++ b/trytond/trytond/__init__.py @@ -16,6 +16,7 @@ except ImportError: requests_utils = None __version__ = "7.5.0" +__series__ = '.'.join(__version__.split('.')[:2]) os.environ.setdefault( 'TRYTOND_APPNAME', diff --git a/trytond/trytond/admin.py b/trytond/trytond/admin.py index db0b09e3fc..e14c6dc978 100644 --- a/trytond/trytond/admin.py +++ b/trytond/trytond/admin.py @@ -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')]) diff --git a/trytond/trytond/backend/database.py b/trytond/trytond/backend/database.py index c19de03740..fd2b88016a 100644 --- a/trytond/trytond/backend/database.py +++ b/trytond/trytond/backend/database.py @@ -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. ''' diff --git a/trytond/trytond/backend/postgresql/database.py b/trytond/trytond/backend/postgresql/database.py index 46c6cbf493..62b5a4283c 100644 --- a/trytond/trytond/backend/postgresql/database.py +++ b/trytond/trytond/backend/postgresql/database.py @@ -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): diff --git a/trytond/trytond/backend/postgresql/init.sql b/trytond/trytond/backend/postgresql/init.sql index 278d3e48b4..1ad216a3b5 100644 --- a/trytond/trytond/backend/postgresql/init.sql +++ b/trytond/trytond/backend/postgresql/init.sql @@ -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) diff --git a/trytond/trytond/backend/sqlite/database.py b/trytond/trytond/backend/sqlite/database.py index 0fc1c82c97..c7906ec91c 100644 --- a/trytond/trytond/backend/sqlite/database.py +++ b/trytond/trytond/backend/sqlite/database.py @@ -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} diff --git a/trytond/trytond/ir/configuration.py b/trytond/trytond/ir/configuration.py index f8f0f74180..2aa880da89 100644 --- a/trytond/trytond/ir/configuration.py +++ b/trytond/trytond/ir/configuration.py @@ -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') diff --git a/trytond/trytond/modules/__init__.py b/trytond/trytond/modules/__init__.py index ce071a13ac..9ee7c69c17 100644 --- a/trytond/trytond/modules/__init__.py +++ b/trytond/trytond/modules/__init__.py @@ -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: diff --git a/trytond/trytond/protocols/wrappers.py b/trytond/trytond/protocols/wrappers.py index 8f232a87c8..85ea999b49 100644 --- a/trytond/trytond/protocols/wrappers.py +++ b/trytond/trytond/protocols/wrappers.py @@ -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): diff --git a/trytond/trytond/security.py b/trytond/trytond/security.py index 0c77f412e8..94601e01ea 100644 --- a/trytond/trytond/security.py +++ b/trytond/trytond/security.py @@ -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()