forked from explorerhq/sql-explorer
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Issue explorerhq#456: Use of Django connection name rather than alias…
… as lookup This commit changes connection behavior from storing the database connection name to using the database alias mapped by SQL Explorer instead. The reason for this change is two-fold: 1) Views take the connection name as input, allowing anyone who knows Django connection names to query those databases, even if SQL does not expose the connection directly. 2) `Query` stores the connection name, which means that if the Django connection name changes or a different connection should be used (for example, one with reduced permissions) the stored Query will either stop working or at least continue using the old connection This change modifies `ExplorerConnections` from being a dictionary that proxies the Django connection dictionary to a dictionary-like object that uses `EXPLORER_CONNECTIONS` to lookup and validate the requested connection alias. In addition all code that used to the `EXPLORER_CONNECTIONS` value now uses the key instead. For backwards compatibility, a migration will back-populate the alias into `Query` instances (and fail if the mapping no longer exists), `EXPLORER_DEFAULT_CONNECTION` is re-written on start-up to use the alias in case it still uses the Django Connection name and `ExplorerConnections` will still accept a Django Connection name as long as that name is exposed by some alias in `EXPLORER_CONNECTIONS`.
- Loading branch information
Showing
18 changed files
with
231 additions
and
70 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,24 +1,61 @@ | ||
import importlib | ||
import logging | ||
|
||
from django.db import connections as djcs | ||
|
||
from explorer.app_settings import EXPLORER_CONNECTIONS | ||
from explorer.utils import InvalidExplorerConnectionException | ||
|
||
|
||
# We export valid SQL connections here so that consuming code never has to | ||
# deal with django.db.connections directly, and risk accessing a connection | ||
# that hasn't been registered to Explorer. | ||
|
||
# Django insists that connections that are created in a thread are only accessed | ||
# by that thread, so here we create a dictionary-like collection of the valid | ||
# connections, but does a 'live' lookup of the connection on each item access. | ||
logger = logging.getLogger(__name__) | ||
|
||
|
||
_connections = {c: c for c in djcs if c in EXPLORER_CONNECTIONS.values()} | ||
class ExplorerConnections: | ||
|
||
|
||
class ExplorerConnections(dict): | ||
def get(self, item, default=None): | ||
try: | ||
return self[item] | ||
except InvalidExplorerConnectionException: | ||
return default | ||
|
||
def __getitem__(self, item): | ||
return djcs[item] | ||
|
||
|
||
connections = ExplorerConnections(_connections) | ||
conn = EXPLORER_CONNECTIONS.get(item) | ||
if not conn: | ||
if item in djcs: | ||
# Original connection handling did lookups by the django names not the explorer | ||
# alias. To support stored uses of URLs accessing connections by the old name | ||
# (such as schema), we support the django db connectin name as long as it is | ||
# mapped by some alias in EXPLORER_CONNECTIONS, so as to prevent access to | ||
# Django DB connections never meant to be exposed by Explorer | ||
if item not in EXPLORER_CONNECTIONS.values(): | ||
raise InvalidExplorerConnectionException( | ||
f"Attempted to access connection {item} which is " | ||
f"not a Django DB connection exposed by Explorer" | ||
) | ||
logger.info(f"using legacy lookup by django connection name for '{item}'") | ||
conn = item | ||
else: | ||
raise InvalidExplorerConnectionException( | ||
f'Attempted to access connection {item}, ' | ||
f'but that is not a registered Explorer connection.' | ||
) | ||
# Django insists that connections that are created in a thread are only accessed | ||
# by that thread, so we do a 'live' lookup of the connection on each item access. | ||
return djcs[conn] | ||
|
||
def __contains__(self, item): | ||
return item in EXPLORER_CONNECTIONS | ||
|
||
def __len__(self): | ||
return len(EXPLORER_CONNECTIONS) | ||
|
||
def keys(self): | ||
return EXPLORER_CONNECTIONS.keys() | ||
|
||
def values(self): | ||
return [self[v] for v in EXPLORER_CONNECTIONS.values()] | ||
|
||
def items(self): | ||
return [(k, self[v]) for k, v in EXPLORER_CONNECTIONS.items()] | ||
|
||
|
||
connections = ExplorerConnections() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,49 @@ | ||
from django.db import migrations | ||
|
||
from explorer.app_settings import EXPLORER_CONNECTIONS | ||
|
||
|
||
def forward(apps, schema_editor): | ||
Query = apps.get_model("explorer", "Query") | ||
|
||
reverse_map = {v: k for k, v in EXPLORER_CONNECTIONS.items()} | ||
|
||
for q in Query.objects.all(): | ||
conn = q.connection | ||
new_conn = reverse_map.get(conn) | ||
if not new_conn: | ||
raise Exception( | ||
f"Query({q.id}) references Django DB connection '{conn}' " | ||
f"which has no alias defined in EXPLORER_CONNECTIONS." | ||
) | ||
if conn == new_conn: | ||
continue | ||
q.connection = new_conn | ||
q.save() | ||
|
||
|
||
def reverse(apps, schema_editor): | ||
Query = apps.get_model("explorer", "Query") | ||
|
||
for q in Query.objects.all(): | ||
conn = q.connection | ||
new_conn = EXPLORER_CONNECTIONS.get(conn) | ||
if not new_conn: | ||
raise Exception( | ||
f"Query({q.id}) references Connection alias '{conn}' " | ||
f"which has no Django DB connection defined in EXPLORER_CONNECTIONS." | ||
) | ||
if conn == new_conn: | ||
continue | ||
q.connection = new_conn | ||
q.save() | ||
|
||
|
||
class Migration(migrations.Migration): | ||
dependencies = [ | ||
('explorer', '0010_sql_required'), | ||
] | ||
|
||
operations = [ | ||
migrations.RunPython(forward, reverse), | ||
] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,20 +1,47 @@ | ||
# -*- coding: utf-8 -*- | ||
from unittest.mock import patch | ||
from unittest.mock import patch, Mock | ||
|
||
from django.core.exceptions import ImproperlyConfigured | ||
from django.test import TestCase | ||
|
||
from explorer.app_settings import EXPLORER_CONNECTIONS | ||
from explorer.apps import _validate_connections | ||
|
||
|
||
class TestApps(TestCase): | ||
|
||
@patch('explorer.apps._get_default') | ||
def test_validates_default_connections(self, mocked_connection): | ||
mocked_connection.return_value = 'garbage' | ||
self.assertRaises(ImproperlyConfigured, _validate_connections) | ||
@patch('explorer.apps._get_app_settings') | ||
def test_validates_default_connections(self, mock_get_settings): | ||
mock_settings = Mock() | ||
mock_settings.EXPLORER_DEFAULT_CONNECTION = 'garbage' | ||
mock_settings.EXPLORER_CONNECTIONS = EXPLORER_CONNECTIONS | ||
mock_get_settings.return_value = mock_settings | ||
|
||
@patch('explorer.apps._get_explorer_connections') | ||
def test_validates_all_connections(self, mocked_connections): | ||
mocked_connections.return_value = {'garbage1': 'in', 'garbage2': 'out'} | ||
self.assertRaises(ImproperlyConfigured, _validate_connections) | ||
with self.assertRaisesMessage( | ||
ImproperlyConfigured, | ||
"EXPLORER_DEFAULT_CONNECTION is garbage, but that " | ||
"alias is not present in the values of EXPLORER_CONNECTIONS" | ||
): | ||
_validate_connections() | ||
|
||
@patch('explorer.apps._get_app_settings') | ||
def test_rewrites_default_connection_if_referencing_django_db_name(self, mock_get_settings): | ||
mock_settings = Mock() | ||
mock_settings.EXPLORER_DEFAULT_CONNECTION = 'default' | ||
mock_settings.EXPLORER_CONNECTIONS = EXPLORER_CONNECTIONS | ||
mock_get_settings.return_value = mock_settings | ||
_validate_connections() | ||
self.assertEqual("SQLite", mock_settings.EXPLORER_DEFAULT_CONNECTION) | ||
|
||
@patch('explorer.apps._get_app_settings') | ||
def test_validates_all_connections(self, mock_get_settings): | ||
mock_settings = Mock() | ||
mock_settings.EXPLORER_DEFAULT_CONNECTION = 'garbage1' | ||
mock_settings.EXPLORER_CONNECTIONS = {'garbage1': 'in', 'garbage2': 'out'} | ||
mock_get_settings.return_value = mock_settings | ||
with self.assertRaisesMessage( | ||
ImproperlyConfigured, | ||
"EXPLORER_CONNECTIONS contains (garbage1, in), " | ||
"but in is not a valid Django DB connection." | ||
): | ||
_validate_connections() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,43 @@ | ||
from django.test import TestCase | ||
|
||
from explorer.connections import connections | ||
from explorer.app_settings import EXPLORER_DEFAULT_CONNECTION | ||
from django.db import connections as djcs | ||
|
||
from explorer.utils import InvalidExplorerConnectionException | ||
|
||
|
||
class TestConnections(TestCase): | ||
|
||
def test_only_registered_connections_are_in_connections(self): | ||
self.assertTrue(EXPLORER_DEFAULT_CONNECTION in connections) | ||
self.assertNotEqual(len(connections), len([c for c in djcs])) | ||
|
||
def test__can_check_for_connection_existence(self): | ||
self.assertTrue("SQLite" in connections) | ||
self.assertFalse("garbage" in connections) | ||
|
||
def test__keys__are_all_aliases(self): | ||
self.assertEqual({'SQLite', 'Another'}, set(connections.keys())) | ||
|
||
def test__values__are_only_registered_db_connections(self): | ||
self.assertEqual({'default', 'alt'}, {c.alias for c in connections.values()}) | ||
|
||
def test__can_lookup_connection_by_DJCS_name_if_registered(self): | ||
c = connections['default'] | ||
self.assertEqual(c, djcs['default']) | ||
|
||
def test__cannot_lookup_connection_by_DJCS_name_if_not_registered(self): | ||
with self.assertRaisesMessage( | ||
InvalidExplorerConnectionException, | ||
"Attempted to access connection not_registered which is not a Django DB connection exposed by Explorer" | ||
): | ||
_ = connections['not_registered'] | ||
|
||
def test__raises_on_unknown_connection_name(self): | ||
with self.assertRaisesMessage( | ||
InvalidExplorerConnectionException, | ||
'Attempted to access connection garbage, ' | ||
'but that is not a registered Explorer connection.' | ||
): | ||
_ = connections['garbage'] |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.