diff --git a/.github/actions/prepare_poetry_env/action.yml b/.github/actions/prepare_poetry_env/action.yml new file mode 100644 index 0000000..2bf84c7 --- /dev/null +++ b/.github/actions/prepare_poetry_env/action.yml @@ -0,0 +1,19 @@ +name: 'Prepare Poetry environment' +description: 'This composite actions checks out out the project, installs Poetry, and install the project in the Poetry environment' +inputs: + python-version: + description: 'The Python version to use' + required: true + default: '3.8' +runs: + using: "composite" + steps: + - uses: actions/setup-python@v2 + with: + python-version: ${{ inputs.python-version }} + - uses: abatilo/actions-poetry@v2 + with: + poetry-version: 1.4.0 + - name: Poetry install + run: poetry install + shell: bash diff --git a/.github/workflows/ci_build.yaml b/.github/workflows/ci_build.yaml new file mode 100644 index 0000000..59c8738 --- /dev/null +++ b/.github/workflows/ci_build.yaml @@ -0,0 +1,23 @@ +name: CI Build + +on: + push: + branches-ignore: + - "main" + +jobs: + run_unit_tests: + runs-on: ubuntu-latest + concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Python & Poetry Environment + uses: ./.github/actions/prepare_poetry_env + + - name: Run pytest + run: poetry run pytest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ce8408 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea +.pytest_cache +dist +__pycache__/ +/TAGS diff --git a/doc/changes/changelog.md b/doc/changes/changelog.md new file mode 100644 index 0000000..5aa79e5 --- /dev/null +++ b/doc/changes/changelog.md @@ -0,0 +1,12 @@ +# Changes + +* [0.0.1](changes_0.0.1.md) + + +```{toctree} +--- +hidden: +--- +changes_0.0.1 + +``` \ No newline at end of file diff --git a/doc/changes/changes_0.0.1.md b/doc/changes/changes_0.0.1.md new file mode 100644 index 0000000..7d6e01f --- /dev/null +++ b/doc/changes/changes_0.0.1.md @@ -0,0 +1,9 @@ +# Notebook Connector 0.0.1, released t.b.d. + +## Summary + +This release adds the initial implementation of the secret store + +## Changes + +* #1: Added secret store diff --git a/error_code_config.yml b/error_code_config.yml new file mode 100644 index 0000000..b3779b0 --- /dev/null +++ b/error_code_config.yml @@ -0,0 +1,3 @@ +error-tags: + NC: + highest-index: 0 diff --git a/notebook_connector/__init__.py b/notebook_connector/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notebook_connector/secret_store.py b/notebook_connector/secret_store.py new file mode 100644 index 0000000..d50c904 --- /dev/null +++ b/notebook_connector/secret_store.py @@ -0,0 +1,163 @@ +import contextlib +import logging +import os +import pathlib +from pathlib import Path +from dataclasses import dataclass +from sqlcipher3 import dbapi2 as sqlcipher +from typing import List, Optional, Union +from inspect import cleandoc + + +_logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class Table: + name: str + columns: List[str] + + +SECRETS_TABLE = Table("secrets", ["user", "password"]) +CONFIG_ITEMS_TABLE = Table("config_items", ["item"]) + + +@dataclass(frozen=True) +class Credentials: + user: str + password: str + + +class InvalidPassword(Exception): + """Signal potentially incorrect master password.""" + + +class Secrets: + def __init__(self, db_file: Path, master_password: str) -> None: + self.db_file = db_file + self._master_password = master_password + self._con = None + + def close(self) -> None: + if self._con is not None: + self._con.close() + self._con = None + + def connection(self) -> sqlcipher.Connection: + if self._con is None: + db_file_found = pathlib.Path.exists(self.db_file) + if not db_file_found: + _logger.info(f"Creating file {self.db_file}") + self._con = sqlcipher.connect(self.db_file) + self._use_master_password() + self._initialize(db_file_found) + return self._con + + def _initialize(self, db_file_found: bool) -> None: + if db_file_found: + self._verify_access() + return + + def create_table(table: Table) -> None: + _logger.info(f'Creating table "{table.name}".') + columns = " ,".join(table.columns) + with self._cursor() as cur: + cur.execute(f"CREATE TABLE {table.name} (key, {columns})") + + for table in (SECRETS_TABLE, CONFIG_ITEMS_TABLE): + create_table(table) + + def _use_master_password(self) -> None: + """ + If database is unencrypted then this method encrypts it. + If database is already encrypted then this method enables to access the data. + """ + if self._master_password is not None: + sanitized = self._master_password.replace("'", "\\'") + with self._cursor() as cur: + cur.execute(f"PRAGMA key = '{sanitized}'") + + def _verify_access(self): + try: + with self._cursor() as cur: + cur.execute("SELECT * FROM sqlite_master") + except sqlcipher.DatabaseError as ex: + print(f'exception {ex}') + if str(ex) == "file is not a database": + raise InvalidPassword( + cleandoc( + f""" + Cannot access + database file {self.db_file}. + This also happens if master password is incorrect. + """) + ) from ex + else: + raise ex + + @contextlib.contextmanager + def _cursor(self) -> sqlcipher.Cursor: + cur = self.connection().cursor() + try: + yield cur + self.connection().commit() + except: + self.connection().rollback() + raise + finally: + cur.close() + + def _save_data(self, table: Table, key: str, data: List[str]) -> "Secrets": + def entry_exists(cur) -> None: + res = cur.execute( + f"SELECT * FROM {table.name} WHERE key=?", + [key]) + return res and res.fetchone() + + def update(cur) -> None: + columns = ", ".join(f"{c}=?" for c in table.columns) + cur.execute( + f"UPDATE {table.name} SET {columns} WHERE key=?", + data + [key]) + + def insert(cur) -> None: + columns = ",".join(table.columns) + value_slots = ", ".join("?" for c in table.columns) + cur.execute( + ( + f"INSERT INTO {table.name}" + f" (key,{columns})" + f" VALUES (?, {value_slots})" + ), + [key] + data) + + with self._cursor() as cur: + if entry_exists(cur): + update(cur) + else: + insert(cur) + return self + + def save(self, key: str, data: Union[str, Credentials]) -> "Secrets": + """key represents a system, service, or application""" + if isinstance(data, str): + return self._save_data(CONFIG_ITEMS_TABLE, key, [data]) + if isinstance(data, Credentials): + return self._save_data(SECRETS_TABLE, key, [data.user, data.password]) + raise Exception("Unsupported type of data: " + type(data).__name__) + + def _data(self, table: Table, key: str) -> Optional[List[str]]: + columns = ", ".join(table.columns) + with self._cursor() as cur: + res = cur.execute( + f"SELECT {columns} FROM {table.name} WHERE key=?", + [key]) + return res.fetchone() if res else None + + def credentials(self, key: str) -> Optional[Credentials]: + row = self._data(SECRETS_TABLE, key) + return Credentials(row[0], row[1]) if row else None + + def config(self, key: str) -> Optional[str]: + row = self._data(CONFIG_ITEMS_TABLE, key) + return row[0] if row else None diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..3547ab9 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,133 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "exceptiongroup" +version = "1.1.3" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.1.3-py3-none-any.whl", hash = "sha256:343280667a4585d195ca1cf9cef84a4e178c4b6cf2274caef9859782b567d5e3"}, + {file = "exceptiongroup-1.1.3.tar.gz", hash = "sha256:097acd85d473d75af5bb98e41b61ff7fe35efe6675e4f9370ec6ec5126d160e9"}, +] + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.0.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.7" +files = [ + {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, + {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, +] + +[[package]] +name = "packaging" +version = "23.2" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.7" +files = [ + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "pluggy" +version = "1.3.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pluggy-1.3.0-py3-none-any.whl", hash = "sha256:d89c696a773f8bd377d18e5ecda92b7a3793cbe66c87060a6fb58c7b6e1061f7"}, + {file = "pluggy-1.3.0.tar.gz", hash = "sha256:cf61ae8f126ac6f7c451172cf30e3e43d3ca77615509771b3a984a0730651e12"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["pytest", "pytest-benchmark"] + +[[package]] +name = "pytest" +version = "7.4.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, + {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} +iniconfig = "*" +packaging = "*" +pluggy = ">=0.12,<2.0" +tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} + +[package.extras] +testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-mock" +version = "3.11.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-mock-3.11.1.tar.gz", hash = "sha256:7f6b125602ac6d743e523ae0bfa71e1a697a2f5534064528c6ff84c2f7c2fc7f"}, + {file = "pytest_mock-3.11.1-py3-none-any.whl", hash = "sha256:21c279fff83d70763b05f8874cc9cfb3fcacd6d354247a976f9529d19f9acf39"}, +] + +[package.dependencies] +pytest = ">=5.0" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + +[[package]] +name = "sqlcipher3-binary" +version = "0.5.2" +description = "DB-API 2.0 interface for SQLCipher 3.x" +optional = false +python-versions = "*" +files = [ + {file = "sqlcipher3_binary-0.5.2-cp310-cp310-manylinux_2_24_x86_64.whl", hash = "sha256:2f38bfffa0b6c23898c2542216d37c494df95ec1587711ebad3c055efe7d4643"}, + {file = "sqlcipher3_binary-0.5.2-cp311-cp311-manylinux_2_24_x86_64.whl", hash = "sha256:a41f18f77e6988a812ed099c7afda835a6ce3697baed440c2f775c6633aea33a"}, + {file = "sqlcipher3_binary-0.5.2-cp36-cp36m-manylinux_2_24_x86_64.whl", hash = "sha256:df1abf166a9e1b42ba53eaecd539efaf408abb986867be4f72c366a8e267af72"}, + {file = "sqlcipher3_binary-0.5.2-cp37-cp37m-manylinux_2_24_x86_64.whl", hash = "sha256:217845d0fa6c88962ebc2e415a49e85cda694d8d79f2a8907b94c946b40892c3"}, + {file = "sqlcipher3_binary-0.5.2-cp38-cp38-manylinux_2_24_x86_64.whl", hash = "sha256:18f83b12770bb90a8fcceb9a33a8abb2c4b65aee5c3e22493bbf4d8cfa6da0ff"}, + {file = "sqlcipher3_binary-0.5.2-cp39-cp39-manylinux_2_24_x86_64.whl", hash = "sha256:419b45efbbffaebe55a4ce8abb9e561606e87229e81bd524a739a047863b51ad"}, +] + +[[package]] +name = "tomli" +version = "2.0.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.7" +files = [ + {file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"}, + {file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"}, +] + +[metadata] +lock-version = "2.0" +python-versions = ">=3.8.0,<4.0" +content-hash = "4f2989612a99ffcf93b9c8cc88249563dbcb1214355ad8e68c0bf0e9eacb0631" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b35a9ca --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,31 @@ +[tool.poetry] +name = "notebook-connector" +version = "0.0.1" +description = "Components, tools, APIs, and configurations in order to connect Jupyter notebooks to various other systems." + +license = "MIT" + +authors = [ + "Christoph Kuhnke " +] + +[tool.poetry.dependencies] +python = ">=3.8.0,<4.0" +sqlcipher3-binary = ">=0.5.0" + + +[build-system] +requires = ["poetry_core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + + +[tool.poetry.dev-dependencies] +pytest = "^7.1.1" +pytest-mock = "^3.7.0" + +[tool.pytest.ini_options] +minversion = "6.0" + +testpaths = [ + "test" +] diff --git a/test/__init__.py b/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..afef0f1 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,13 @@ +import pytest +from pathlib import Path +from notebook_connector.secret_store import Secrets + + +@pytest.fixture +def sample_file(tmp_path: Path) -> Path: + return tmp_path / "sample_database.db" + + +@pytest.fixture +def secrets(sample_file) -> Path: + return Secrets(sample_file, master_password="abc") diff --git a/test/test_secret_store.py b/test/test_secret_store.py new file mode 100644 index 0000000..fc78ca3 --- /dev/null +++ b/test/test_secret_store.py @@ -0,0 +1,56 @@ +import os +import pytest +from notebook_connector.secret_store import Credentials, InvalidPassword, Secrets +from sqlcipher3 import dbapi2 as sqlcipher + + +def test_no_database_file(secrets): + assert not os.path.exists(secrets.db_file) + + +def test_database_file_from_credentials(secrets): + assert secrets.credentials("a") is None + assert os.path.exists(secrets.db_file) + + +def test_database_file_from_config_item(secrets): + assert secrets.config("a") is None + assert os.path.exists(secrets.db_file) + + +def test_credentials(secrets): + credentials = Credentials("user", "password") + secrets.save("key", credentials).close() + assert secrets.credentials("key") == credentials + + +def test_config_item(secrets): + config_item = "some configuration" + secrets.save("key", config_item).close() + assert secrets.config("key") == config_item + + +def test_update_credentials(secrets): + initial = Credentials("user", "password") + secrets.save("key", initial).close() + other = Credentials("other", "changed") + secrets.save("key", other) + secrets.close() + assert secrets.credentials("key") == other + + +def test_update_config_item(secrets): + initial = "initial value" + secrets.save("key", initial).close() + other = "other value" + secrets.save("key", other).close() + assert secrets.config("key") == other + + +def test_wrong_password(sample_file): + secrets = Secrets(sample_file, "correct password") + secrets.save("key", Credentials("usr", "pass")).close() + invalid = Secrets(sample_file, "wrong password") + with pytest.raises(InvalidPassword) as ex: + invalid.credentials("key") + assert "master password is incorrect" in str(ex.value)