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 Service Implementation #1

Merged
merged 46 commits into from
Dec 20, 2024
Merged
Show file tree
Hide file tree
Changes from 43 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
ec22079
Define `User` config model
NeonDaniel Oct 24, 2024
8dfbbcc
Implement SQLite backend with unit tests
NeonDaniel Oct 25, 2024
ec2ca6c
Start model unit test coverage
NeonDaniel Oct 25, 2024
3da775c
Update default user values to use factory methods
NeonDaniel Oct 25, 2024
326cda4
Add Python packaging
NeonDaniel Oct 25, 2024
11fa413
Add GHA Automation
NeonDaniel Oct 25, 2024
1113ad1
Update Python versions in unit tests
NeonDaniel Oct 25, 2024
82ae3b7
Reformat unit test file
NeonDaniel Oct 25, 2024
60eec3b
Specify configured DoB to use a date object with unit test coverage
NeonDaniel Oct 25, 2024
f541d60
Define AccessRoles usage in docstring with unit test coverage
NeonDaniel Oct 25, 2024
7f7ee80
Refactor `UserNotFoundError` exception name to math File errors
NeonDaniel Oct 25, 2024
193bade
Add `read_user` convenience method to `UserDatabase` base class
NeonDaniel Oct 25, 2024
39ff65b
Add exception if UsersService does not have a valid database configur…
NeonDaniel Oct 25, 2024
f1b1de4
Add default config to package data
NeonDaniel Oct 25, 2024
5278d23
Troubleshooting missing package_data
NeonDaniel Oct 25, 2024
9816572
Remove `max-parallel` limit from unit test automation
NeonDaniel Oct 25, 2024
499c635
Add locking around database operations
NeonDaniel Oct 25, 2024
ae37559
Add unit test coverage for `delete_user`
NeonDaniel Oct 25, 2024
cf33b27
Handle hashing of changed passwords in `update_user`
NeonDaniel Oct 25, 2024
1987049
Add MQ request model for input validation
NeonDaniel Oct 25, 2024
19a1fcf
Update tests to reflect behavior changes
NeonDaniel Oct 25, 2024
07bbd6c
Fix and annotate delete_user tests
NeonDaniel Oct 25, 2024
de0e848
Define `mq_connector` module and document MQ API in README.md
NeonDaniel Oct 26, 2024
d51368f
Implement Docker container for MQ service
NeonDaniel Oct 26, 2024
59a7ca1
Add dockerfile
NeonDaniel Oct 29, 2024
64f08af
Update SQLite to allow threaded access for MQ compat.
NeonDaniel Oct 30, 2024
6c1a73f
Update PermissionsConfig to dump to int for JSON serialization
NeonDaniel Oct 30, 2024
e004b51
Refactor to import models from neon_data_models package
NeonDaniel Nov 4, 2024
fbdc143
Update imports in tests to new module
NeonDaniel Nov 4, 2024
7d01620
Fix missed import change
NeonDaniel Nov 4, 2024
0420d2d
Refactor to move common logic to the base class
NeonDaniel Nov 5, 2024
a30cbd3
Add MongoDb database class with unit tests
NeonDaniel Nov 5, 2024
de259b4
Update dependencies for mongodb
NeonDaniel Nov 5, 2024
a50cf2d
Update MongoDb tests to support parallel runs
NeonDaniel Nov 5, 2024
8d692b1
Add service support for MongoDB
NeonDaniel Nov 6, 2024
c485f14
Update Docker default config to include sqlite database
NeonDaniel Nov 7, 2024
7987ce6
Add and implement a specific PermissionsError exception
NeonDaniel Nov 8, 2024
d0776e9
Update token auth handling to use HanaToken model instead of encoded …
NeonDaniel Nov 12, 2024
9095b54
Refactor permissions checks to match changes made to neon-data-models
NeonDaniel Nov 19, 2024
b34492b
Refactor to remove `RW_USERS` role since the `USER` and `ADMIN` roles…
NeonDaniel Nov 19, 2024
47a5ce8
Refactor imports to troubleshoot circular import exception noted in h…
NeonDaniel Nov 19, 2024
d6634f8
Remove Python 3.8 from unit test coverage
NeonDaniel Nov 20, 2024
9a6f595
Update `neon-data-models` dependency spec
NeonDaniel Nov 21, 2024
b0b10d4
Apply GNU Affero license
NeonDaniel Dec 19, 2024
eab72b9
Add license note to README.md
NeonDaniel Dec 20, 2024
0008610
Add link to GNU Affero description in README text
NeonDaniel Dec 20, 2024
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
12 changes: 12 additions & 0 deletions .github/workflows/license_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
name: Run License Tests
on:
push:
workflow_dispatch:
pull_request:
branches:
- master
jobs:
license_tests:
uses: neongeckocom/.github/.github/workflows/license_tests.yml@master
with:
packages-exclude: '^(neon-users-service).*'
27 changes: 27 additions & 0 deletions .github/workflows/propose_release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Propose Stable Release
on:
workflow_dispatch:
inputs:
release_type:
type: choice
description: Release Type
options:
- patch
- minor
- major
jobs:
update_version:
uses: neongeckocom/.github/.github/workflows/propose_semver_release.yml@master
with:
branch: dev
release_type: ${{ inputs.release_type }}
update_changelog: True
pull_changes:
uses: neongeckocom/.github/.github/workflows/pull_master.yml@master
needs: update_version
with:
pr_reviewer: neonreviewers
pr_assignee: ${{ github.actor }}
pr_draft: false
pr_title: ${{ needs.update_version.outputs.version }}
pr_body: ${{ needs.update_version.outputs.changelog }}
16 changes: 16 additions & 0 deletions .github/workflows/publish_release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# This workflow will generate a release distribution and upload it to PyPI

name: Publish Build and GitHub Release
on:
push:
branches:
- master

jobs:
build_and_publish_pypi_and_release:
uses: neongeckocom/.github/.github/workflows/publish_stable_release.yml@master
secrets: inherit
build_and_publish_docker:
needs: build_and_publish_pypi_and_release
uses: neongeckocom/.github/.github/workflows/publish_docker.yml@master
secrets: inherit
21 changes: 21 additions & 0 deletions .github/workflows/publish_test_build.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# This workflow will generate a distribution and upload it to PyPI

name: Publish Alpha Build
on:
push:
branches:
- dev
paths-ignore:
- 'version.py'

jobs:
publish_alpha_release:
uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master
secrets: inherit
with:
version_file: "version.py"
publish_prerelease: true
build_and_publish_docker:
needs: publish_alpha_release
uses: neongeckocom/.github/.github/workflows/publish_docker.yml@master
secrets: inherit
42 changes: 42 additions & 0 deletions .github/workflows/unit_tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# This workflow will run unit tests

name: Run Unit Tests
on:
push:
workflow_dispatch:
pull_request:
branches:
- master

jobs:
py_build_tests:
uses: neongeckocom/.github/.github/workflows/python_build_tests.yml@master
with:
python_version: "3.8"
docker_build_tests:
uses: neongeckocom/.github/.github/workflows/docker_build_tests.yml@master
unit_tests:
strategy:
matrix:
python-version: [3.9, '3.10', '3.11', '3.12']
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install .[test,mongodb]
- name: Unit Tests
run: |
pytest tests --doctest-modules --junitxml=tests/unit-test-results.xml
env:
MONGO_TEST_CONFIG: ${{secrets.MONGO_TEST_CONFIG}}
- name: Upload test results
uses: actions/upload-artifact@v4
with:
name: unit-test-results-${{matrix.python-version}}
path: tests/unit-test-results.xml
23 changes: 23 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.10-slim

LABEL vendor=neon.ai \
ai.neon.name="neon-users-service"

ENV OVOS_CONFIG_BASE_FOLDER=neon
ENV OVOS_CONFIG_FILENAME=diana.yaml
ENV XDG_CONFIG_HOME=/config
ENV XDG_DATA_HOME=/data
COPY docker_overlay/ /

RUN apt-get update && \
apt-get install -y \
gcc \
python3 \
python3-dev \
&& pip install wheel

ADD . /neon_users_service
WORKDIR /neon_users_service
RUN pip install .[mq,mongodb]

CMD ["neon_users_service"]
73 changes: 73 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Neon Users Service
This module manages access to a pluggable user database backend. By default, it
operates as a standalone module using SQLite as the persistent data store.

## Configuration
Configuration may be passed directly to the `NeonUsersService` constructor,
otherwise it will read from a config file using `ovos-config`. The configuration
file will be `~/.config/neon/diana.yaml` by default. An example valid configuration
is included:

```yaml
neon_users_service:
module: sqlite
sqlite:
db_path: ~/.local/share/neon/user-db.sqlite
```

`module` defines the backend to use and a config key matching that backend
will specify the kwargs passed to the initialization of that module.

## MQ Integration
The `mq_connector` module provides an MQ entrypoint to services and is the
primary method of interaction with this service. Valid requests are detailed
below. Responses will always follow the form:

```yaml
success: False
error: <string description>
```

```yaml
success: True
user: <serialized User object>
```

### Create
Create a new user by sending a request with the following parameters:
```yaml
operation: create
username: <new_username>
password: <new_password>
user: <Optional serialized User object, else default will be created>
```

### Read
Read an existing user. If `password` is not supplied, then the returned User
object will have the `password_hash` and `tokens` config redacted.
```yaml
operation: read
username: <existing_username>
password: <existing_password>
```

### Update
Update an existing user. If a `password` is supplied, it will replace the
user's current password. If no `password` is supplied and `user.password_hash`
is updated, the database entry will be updated with that new value.

```yaml
operation: update
username: <existing_username>
password: <optional new password>
user: <updated User object>
```

### Delete
Delete an existing user. This requires that the supplied `user` object matches
an entry in the database exactly for validation.
```yaml
operation: delete
username: <username_to_delete>
user: <User object to delete>
```
Empty file.
13 changes: 13 additions & 0 deletions docker_overlay/etc/neon/diana.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
log_level: INFO
logs:
level_overrides:
error:
- pika
warning:
- filelock
info: []
debug: []
neon_users_service:
module: sqlite
sqlite:
db_path: /data/neon-users-db.sqlite
6 changes: 6 additions & 0 deletions neon_users_service/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from os import environ
from os.path import join, dirname

environ.setdefault('OVOS_CONFIG_FILENAME', "diana.yaml")
environ.setdefault('OVOS_CONFIG_BASE_FOLDER', "neon")
environ.setdefault('OVOS_DEFAULT_CONFIG', join(dirname(__file__), "default_config.yaml"))
18 changes: 18 additions & 0 deletions neon_users_service/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from neon_users_service.mq_connector import NeonUsersConnector
from ovos_utils import wait_for_exit_signal
from ovos_utils.log import LOG, init_service_logger

init_service_logger("neon-users-service")


def main():
connector = NeonUsersConnector(None)
LOG.info("Starting Neon Users Service")
connector.run()
LOG.info("Started Neon Users Service")
wait_for_exit_signal()
LOG.info("Shut down")


if __name__ == "__main__":
main()
132 changes: 132 additions & 0 deletions neon_users_service/databases/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
from abc import ABC, abstractmethod

from neon_users_service.exceptions import UserNotFoundError, UserExistsError
from neon_data_models.models.user import User


class UserDatabase(ABC):
def create_user(self, user: User) -> User:
"""
Add a new user to the database. Raises a `UserExistsError` if the input
`user` already exists in the database (by `username` or `user_id`).
@param user: `User` object to insert to the database
@return: `User` object inserted into the database
"""
if self._check_user_exists(user):
raise UserExistsError(user)
return self._db_create_user(user)

@abstractmethod
def _db_create_user(self, user: User) -> User:
"""
Add a new user to the database. The `user` object has already been
validated as unique, so this just needs to perform the database
transaction.
@param user: `User` object to insert to the database
@return: `User` object inserted into the database
"""

@abstractmethod
def read_user_by_id(self, user_id: str) -> User:
"""
Get a `User` object by `user_id`. Raises a `UserNotFoundError` if the
input `user_id` is not found in the database
@param user_id: `user_id` to look up
@return: `User` object parsed from the database
"""

@abstractmethod
def read_user_by_username(self, username: str) -> User:
"""
Get a `User` object by `username`. Note that `username` is not
guaranteed to be static. Raises a `UserNotFoundError` if the
input `username` is not found in the database
@param username: `username` to look up
@return: `User` object parsed from the database
"""

def read_user(self, user_spec: str) -> User:
"""
Get a `User` object by username or user_id. Raises a
`UserNotFoundError` if the user is not found. `user_id` is given priority
over `username`; it is possible (though unlikely) that a username
exists with the same spec as another user's user_id.
"""
try:
return self.read_user_by_id(user_spec)
except UserNotFoundError:
return self.read_user_by_username(user_spec)

def update_user(self, user: User) -> User:
"""
Update a user entry in the database. Raises a `UserNotFoundError` if
the input user's `user_id` is not found in the database.
@param user: `User` object to update in the database
@return: Updated `User` object read from the database
"""
# Lookup user to ensure they exist in the database
existing_id = self.read_user_by_id(user.user_id)
try:
if self.read_user_by_username(user.username) != existing_id:
raise UserExistsError(f"Another user with username "
f"'{user.username}' already exists")
except UserNotFoundError:
pass
return self._db_update_user(user)

@abstractmethod
def _db_update_user(self, user: User) -> User:
"""
Update a user entry in the database. The `user` object has already been
validated as existing and changes valid, so this just needs to perform
the database transaction.
@param user: `User` object to update in the database
@return: Updated `User` object read from the database
"""

def delete_user(self, user_id: str) -> User:
"""
Remove a user from the database if it exists. Raises a
`UserNotFoundError` if the input user's `user_id` is not found in the
database.
@param user_id: `user_id` to remove
@return: User object removed from the database
"""
# Lookup user to ensure they exist in the database
user_to_delete = self.read_user_by_id(user_id)
return self._db_delete_user(user_to_delete)

@abstractmethod
def _db_delete_user(self, user: User) -> User:
"""
Remove a user from the database if it exists. The `user` object has
already been validated as existing, so this just needs to perform the
database transaction.
@param user: User object to remove
@return: User object removed from the database
"""

def _check_user_exists(self, user: User) -> bool:
"""
Check if a user already exists with the given `username` or `user_id`.
"""
try:
# If username is defined, raise an exception
if self.read_user_by_username(user.username):
return True
except UserNotFoundError:
pass
try:
# If user ID is defined, it was likely passed to the `User` object
# instead of allowing the Factory to generate a new one.
if self.read_user_by_id(user.user_id):
return True
except UserNotFoundError:
pass
return False

def shutdown(self):
"""
Perform any cleanup when a database is no longer being used
"""
pass
Loading
Loading