Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial commit #2

Merged
merged 8 commits into from
Oct 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/actions/prepare_poetry_env/action.yml
Original file line number Diff line number Diff line change
@@ -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'
ckunki marked this conversation as resolved.
Show resolved Hide resolved
runs:
using: "composite"
steps:
- uses: actions/setup-python@v2
with:
python-version: ${{ inputs.python-version }}
ckunki marked this conversation as resolved.
Show resolved Hide resolved
- uses: abatilo/actions-poetry@v2
with:
poetry-version: 1.4.0
- name: Poetry install
run: poetry install
shell: bash
kaklakariada marked this conversation as resolved.
Show resolved Hide resolved
23 changes: 23 additions & 0 deletions .github/workflows/ci_build.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
name: CI Build

on:
push:
branches-ignore:
- "main"

jobs:
run_unit_tests:
runs-on: ubuntu-latest
ckunki marked this conversation as resolved.
Show resolved Hide resolved
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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.idea
.pytest_cache
dist
__pycache__/
/TAGS
12 changes: 12 additions & 0 deletions doc/changes/changelog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Changes

* [0.0.1](changes_0.0.1.md)

<!--- This MyST Parser Sphinx directive is necessary to keep Sphinx happy. We need list here all release letters again, because release droid and other scripts assume Markdown --->
```{toctree}
---
hidden:
---
changes_0.0.1

```
9 changes: 9 additions & 0 deletions doc/changes/changes_0.0.1.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions error_code_config.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
error-tags:
NC:
highest-index: 0
Empty file added notebook_connector/__init__.py
Empty file.
163 changes: 163 additions & 0 deletions notebook_connector/secret_store.py
ckunki marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -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"])
ckunki marked this conversation as resolved.
Show resolved Hide resolved
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
133 changes: 133 additions & 0 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading