diff --git a/.github/workflows/license_tests.yml b/.github/workflows/license_tests.yml
new file mode 100644
index 0000000..7bfcd50
--- /dev/null
+++ b/.github/workflows/license_tests.yml
@@ -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).*'
\ No newline at end of file
diff --git a/.github/workflows/propose_release.yml b/.github/workflows/propose_release.yml
new file mode 100644
index 0000000..81dfe43
--- /dev/null
+++ b/.github/workflows/propose_release.yml
@@ -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 }}
\ No newline at end of file
diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml
new file mode 100644
index 0000000..ff73f4a
--- /dev/null
+++ b/.github/workflows/publish_release.yml
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/publish_test_build.yml b/.github/workflows/publish_test_build.yml
new file mode 100644
index 0000000..9fe69c5
--- /dev/null
+++ b/.github/workflows/publish_test_build.yml
@@ -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
\ No newline at end of file
diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
new file mode 100644
index 0000000..21af182
--- /dev/null
+++ b/.github/workflows/unit_tests.yml
@@ -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
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..63d9d5f
--- /dev/null
+++ b/Dockerfile
@@ -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"]
\ No newline at end of file
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..5469257
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,660 @@
+# GNU AFFERO GENERAL PUBLIC LICENSE
+
+Version 3, 19 November 2007
+
+Copyright (C) 2007 Free Software Foundation, Inc.
+
+
+Everyone is permitted to copy and distribute verbatim copies of this
+license document, but changing it is not allowed.
+
+## Preamble
+
+The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains
+free software for all its users.
+
+When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing
+under this license.
+
+The precise terms and conditions for copying, distribution and
+modification follow.
+
+## TERMS AND CONDITIONS
+
+### 0. Definitions.
+
+"This License" refers to version 3 of the GNU Affero General Public
+License.
+
+"Copyright" also means copyright-like laws that apply to other kinds
+of works, such as semiconductor masks.
+
+"The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of
+an exact copy. The resulting work is called a "modified version" of
+the earlier work or a work "based on" the earlier work.
+
+A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user
+through a computer network, with no transfer of a copy, is not
+conveying.
+
+An interactive user interface displays "Appropriate Legal Notices" to
+the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+### 1. Source Code.
+
+The "source code" for a work means the preferred form of the work for
+making modifications to it. "Object code" means any non-source form of
+a work.
+
+A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can
+regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same
+work.
+
+### 2. Basic Permissions.
+
+All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey,
+without conditions so long as your license otherwise remains in force.
+You may convey covered works to others for the sole purpose of having
+them make modifications exclusively for you, or provide you with
+facilities for running those works, provided that you comply with the
+terms of this License in conveying all material for which you do not
+control copyright. Those thus making or running the covered works for
+you must do so exclusively on your behalf, under your direction and
+control, on terms that prohibit them from making any copies of your
+copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the
+conditions stated below. Sublicensing is not allowed; section 10 makes
+it unnecessary.
+
+### 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such
+circumvention is effected by exercising rights under this License with
+respect to the covered work, and you disclaim any intention to limit
+operation or modification of the work as a means of enforcing, against
+the work's users, your or third parties' legal rights to forbid
+circumvention of technological measures.
+
+### 4. Conveying Verbatim Copies.
+
+You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+### 5. Conveying Modified Source Versions.
+
+You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these
+conditions:
+
+- a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+- b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under
+ section 7. This requirement modifies the requirement in section 4
+ to "keep intact all notices".
+- c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+- d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+### 6. Conveying Non-Source Forms.
+
+You may convey a covered work in object code form under the terms of
+sections 4 and 5, provided that you also convey the machine-readable
+Corresponding Source under the terms of this License, in one of these
+ways:
+
+- a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+- b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the Corresponding
+ Source from a network server at no charge.
+- c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+- d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+- e) Convey the object code using peer-to-peer transmission,
+ provided you inform other peers where the object code and
+ Corresponding Source of the work are being offered to the general
+ public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal,
+family, or household purposes, or (2) anything designed or sold for
+incorporation into a dwelling. In determining whether a product is a
+consumer product, doubtful cases shall be resolved in favor of
+coverage. For a particular product received by a particular user,
+"normally used" refers to a typical or common use of that class of
+product, regardless of the status of the particular user or of the way
+in which the particular user actually uses, or expects or is expected
+to use, the product. A product is a consumer product regardless of
+whether the product has substantial commercial, industrial or
+non-consumer uses, unless such uses represent the only significant
+mode of use of the product.
+
+"Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to
+install and execute modified versions of a covered work in that User
+Product from a modified version of its Corresponding Source. The
+information must suffice to ensure that the continued functioning of
+the modified object code is in no case prevented or interfered with
+solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or
+updates for a work that has been modified or installed by the
+recipient, or for the User Product in which it has been modified or
+installed. Access to a network may be denied when the modification
+itself materially and adversely affects the operation of the network
+or violates the rules and protocols for communication across the
+network.
+
+Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+### 7. Additional Terms.
+
+"Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders
+of that material) supplement the terms of this License with terms:
+
+- a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+- b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+- c) Prohibiting misrepresentation of the origin of that material,
+ or requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+- d) Limiting the use for publicity purposes of names of licensors
+ or authors of the material; or
+- e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+- f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions
+ of it) with contractual assumptions of liability to the recipient,
+ for any liability that these contractual assumptions directly
+ impose on those licensors and authors.
+
+All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions; the
+above requirements apply either way.
+
+### 8. Termination.
+
+You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+However, if you cease all violation of this License, then your license
+from a particular copyright holder is reinstated (a) provisionally,
+unless and until the copyright holder explicitly and finally
+terminates your license, and (b) permanently, if the copyright holder
+fails to notify you of the violation by some reasonable means prior to
+60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+### 9. Acceptance Not Required for Having Copies.
+
+You are not required to accept this License in order to receive or run
+a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+### 10. Automatic Licensing of Downstream Recipients.
+
+Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+### 11. Patents.
+
+A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+A contributor's "essential patent claims" are all patent claims owned
+or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+A patent license is "discriminatory" if it does not include within the
+scope of its coverage, prohibits the exercise of, or is conditioned on
+the non-exercise of one or more of the rights that are specifically
+granted under this License. You may not convey a covered work if you
+are a party to an arrangement with a third party that is in the
+business of distributing software, under which you make payment to the
+third party based on the extent of your activity of conveying the
+work, and under which the third party grants, to any of the parties
+who would receive the covered work from you, a discriminatory patent
+license (a) in connection with copies of the covered work conveyed by
+you (or copies made from those copies), or (b) primarily for and in
+connection with specific products or compilations that contain the
+covered work, unless you entered into that arrangement, or that patent
+license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+### 12. No Surrender of Others' Freedom.
+
+If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under
+this License and any other pertinent obligations, then as a
+consequence you may not convey it at all. For example, if you agree to
+terms that obligate you to collect a royalty for further conveying
+from those to whom you convey the Program, the only way you could
+satisfy both those terms and this License would be to refrain entirely
+from conveying the Program.
+
+### 13. Remote Network Interaction; Use with the GNU General Public License.
+
+Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your
+version supports such interaction) an opportunity to receive the
+Corresponding Source of your version by providing access to the
+Corresponding Source from a network server at no charge, through some
+standard or customary means of facilitating copying of software. This
+Corresponding Source shall include the Corresponding Source for any
+work covered by version 3 of the GNU General Public License that is
+incorporated pursuant to the following paragraph.
+
+Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+### 14. Revised Versions of this License.
+
+The Free Software Foundation may publish revised and/or new versions
+of the GNU Affero General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program
+specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever
+published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions
+of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+### 15. Disclaimer of Warranty.
+
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
+WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
+PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE
+DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR
+CORRECTION.
+
+### 16. Limitation of Liability.
+
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR
+CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
+INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
+ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT
+NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR
+LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM
+TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER
+PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+### 17. Interpretation of Sections 15 and 16.
+
+If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS
+
+## How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these
+terms.
+
+To do so, attach the following notices to the program. It is safest to
+attach them to the start of each source file to most effectively state
+the exclusion of warranty; and each file should have at least the
+"copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as
+ published by the Free Software Foundation, either version 3 of the
+ License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper
+mail.
+
+If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for
+the specific requirements.
+
+You should also get your employer (if you work as a programmer) or
+school, if any, to sign a "copyright disclaimer" for the program, if
+necessary. For more information on this, and how to apply and follow
+the GNU AGPL, see .
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2f5da5a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,79 @@
+# 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:
+```
+
+```yaml
+success: True
+user:
+```
+
+### Create
+Create a new user by sending a request with the following parameters:
+```yaml
+operation: create
+username:
+password:
+user:
+```
+
+### 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:
+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:
+password:
+user:
+```
+
+### 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:
+user:
+```
+
+___
+### Licensing
+This project is free to use under the
+[GNU Affero General Public License](https://www.gnu.org/licenses/why-affero-gpl.html).
+Contact info@neon.ai for commercial licensing options.
diff --git a/docker_overlay/config/neon/.keep b/docker_overlay/config/neon/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/docker_overlay/etc/neon/diana.yaml b/docker_overlay/etc/neon/diana.yaml
new file mode 100644
index 0000000..a757764
--- /dev/null
+++ b/docker_overlay/etc/neon/diana.yaml
@@ -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
\ No newline at end of file
diff --git a/neon_users_service/__init__.py b/neon_users_service/__init__.py
new file mode 100644
index 0000000..b94ee4b
--- /dev/null
+++ b/neon_users_service/__init__.py
@@ -0,0 +1,21 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+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"))
diff --git a/neon_users_service/__main__.py b/neon_users_service/__main__.py
new file mode 100644
index 0000000..93e60b1
--- /dev/null
+++ b/neon_users_service/__main__.py
@@ -0,0 +1,33 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+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()
diff --git a/neon_users_service/databases/__init__.py b/neon_users_service/databases/__init__.py
new file mode 100644
index 0000000..3524146
--- /dev/null
+++ b/neon_users_service/databases/__init__.py
@@ -0,0 +1,147 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+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
diff --git a/neon_users_service/databases/mongodb.py b/neon_users_service/databases/mongodb.py
new file mode 100644
index 0000000..7ac306d
--- /dev/null
+++ b/neon_users_service/databases/mongodb.py
@@ -0,0 +1,60 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from pymongo import MongoClient
+from neon_users_service.databases import UserDatabase
+from neon_data_models.models.user.database import User
+from neon_users_service.exceptions import UserNotFoundError
+
+
+class MongoDbUserDatabase(UserDatabase):
+ def __init__(self, db_host: str, db_port: int, db_user: str, db_pass: str,
+ db_name: str = "neon-users", collection_name: str = "users"):
+ connection_string = f"mongodb://{db_user}:{db_pass}@{db_host}:{db_port}"
+ self.client = MongoClient(connection_string)
+ self.db = self.client[db_name]
+ self.collection = self.db[collection_name]
+
+ def _db_create_user(self, user: User) -> User:
+ self.collection.insert_one({**user.model_dump(),
+ "_id": user.user_id})
+ return self.read_user_by_id(user.user_id)
+
+ def read_user_by_id(self, user_id: str) -> User:
+ result = self.collection.find_one({"user_id": user_id})
+ if not result:
+ raise UserNotFoundError(user_id)
+ return User(**result)
+
+ def read_user_by_username(self, username: str) -> User:
+ result = self.collection.find_one({"username": username})
+ if not result:
+ raise UserNotFoundError(username)
+ return User(**result)
+
+ def _db_update_user(self, user: User) -> User:
+ update = user.model_dump()
+ update.pop("user_id")
+ update.pop("created_timestamp")
+ self.collection.update_one({"user_id": user.user_id},
+ {"$set": update})
+ return self.read_user_by_id(user.user_id)
+
+ def _db_delete_user(self, user: User) -> User:
+ self.collection.delete_one({"user_id": user.user_id})
+ return user
+
+ def shutdown(self):
+ self.client.close()
diff --git a/neon_users_service/databases/sqlite.py b/neon_users_service/databases/sqlite.py
new file mode 100644
index 0000000..29369de
--- /dev/null
+++ b/neon_users_service/databases/sqlite.py
@@ -0,0 +1,107 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import json
+
+from os import makedirs
+from os.path import expanduser, dirname
+from sqlite3 import connect
+from threading import Lock
+from typing import Optional, List
+
+from neon_users_service.databases import UserDatabase
+from neon_users_service.exceptions import UserNotFoundError, DatabaseError
+from neon_data_models.models.user.database import User
+
+
+class SQLiteUserDatabase(UserDatabase):
+ def __init__(self, db_path: Optional[str] = None):
+ db_path = expanduser(db_path or "~/.local/share/neon/user-db.sqlite")
+ makedirs(dirname(db_path), exist_ok=True)
+ self.connection = connect(db_path, check_same_thread=False)
+ self._db_lock = Lock()
+ self.connection.execute(
+ '''CREATE TABLE IF NOT EXISTS users
+ (user_id text,
+ created_timestamp integer,
+ username text,
+ user_object text)'''
+ )
+ self.connection.commit()
+
+ def _db_create_user(self, user: User) -> User:
+ with self._db_lock:
+ self.connection.execute(
+ f'''INSERT INTO users VALUES
+ ('{user.user_id}',
+ '{user.created_timestamp}',
+ '{user.username}',
+ '{user.model_dump_json()}')'''
+ )
+ self.connection.commit()
+ return user
+
+ @staticmethod
+ def _parse_lookup_results(user_spec: str, rows: List[tuple]) -> str:
+ if len(rows) > 1:
+ raise DatabaseError(f"User with spec '{user_spec}' has duplicate entries!")
+ elif len(rows) == 0:
+ raise UserNotFoundError(user_spec)
+ return rows[0][0]
+
+ def read_user_by_id(self, user_id: str) -> User:
+ with self._db_lock:
+ cursor = self.connection.cursor()
+ cursor.execute(
+ f'''SELECT user_object FROM users WHERE
+ user_id = '{user_id}'
+ '''
+ )
+ rows = cursor.fetchall()
+ cursor.close()
+ return User(**json.loads(self._parse_lookup_results(user_id, rows)))
+
+ def read_user_by_username(self, username: str) -> User:
+ with self._db_lock:
+ cursor = self.connection.cursor()
+ cursor.execute(
+ f'''SELECT user_object FROM users WHERE
+ username = '{username}'
+ '''
+ )
+ rows = cursor.fetchall()
+ cursor.close()
+ return User(**json.loads(self._parse_lookup_results(username, rows)))
+
+ def _db_update_user(self, user: User) -> User:
+ with self._db_lock:
+ self.connection.execute(
+ f'''UPDATE users SET username = '{user.username}',
+ user_object = '{user.model_dump_json()}'
+ WHERE user_id = '{user.user_id}'
+ '''
+ )
+ self.connection.commit()
+ return self.read_user_by_id(user.user_id)
+
+ def _db_delete_user(self, user: User) -> User:
+ with self._db_lock:
+ self.connection.execute(
+ f"DELETE FROM users WHERE user_id = '{user.user_id}'")
+ self.connection.commit()
+ return user
+
+ def shutdown(self):
+ self.connection.close()
diff --git a/neon_users_service/default_config.yaml b/neon_users_service/default_config.yaml
new file mode 100644
index 0000000..92cf1bc
--- /dev/null
+++ b/neon_users_service/default_config.yaml
@@ -0,0 +1,4 @@
+neon_users_service:
+ module: sqlite
+ sqlite:
+ db_path: ~/.local/share/neon/user-db.sqlite
\ No newline at end of file
diff --git a/neon_users_service/exceptions.py b/neon_users_service/exceptions.py
new file mode 100644
index 0000000..5d2e07f
--- /dev/null
+++ b/neon_users_service/exceptions.py
@@ -0,0 +1,56 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+class UserExistsError(Exception):
+ """
+ Raised when trying to create a user with a username that already exists.
+ """
+
+
+class UserNotFoundError(Exception):
+ """
+ Raised when trying to look up a user that does not exist.
+ """
+
+
+class UserNotMatchedError(Exception):
+ """
+ Raised when two `User` objects are expected to match and do not.
+ """
+
+
+class ConfigurationError(KeyError):
+ """
+ Raised when service configuration is not valid.
+ """
+
+
+class AuthenticationError(ValueError):
+ """
+ Raised when authentication fails for an existing valid user.
+ """
+
+
+class PermissionsError(Exception):
+ """
+ Raised when a user does not have sufficient permissions to perform the
+ requested action.
+ """
+
+
+class DatabaseError(RuntimeError):
+ """
+ Raised when a database-related error occurs.
+ """
\ No newline at end of file
diff --git a/neon_users_service/mq_connector.py b/neon_users_service/mq_connector.py
new file mode 100644
index 0000000..b00a855
--- /dev/null
+++ b/neon_users_service/mq_connector.py
@@ -0,0 +1,155 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+from typing import Optional
+
+import pika.channel
+from ovos_utils import LOG
+from ovos_config.config import Configuration
+
+from neon_data_models.enum import AccessRoles
+from neon_mq_connector.connector import MQConnector
+from neon_mq_connector.utils.network_utils import b64_to_dict, dict_to_b64
+from neon_users_service.exceptions import UserNotFoundError, AuthenticationError, UserNotMatchedError, UserExistsError
+from neon_data_models.models.api.mq import (UserDbRequest, CreateUserRequest,
+ ReadUserRequest, UpdateUserRequest,
+ DeleteUserRequest)
+
+from neon_users_service.service import NeonUsersService
+
+
+class NeonUsersConnector(MQConnector):
+ def __init__(self, config: Optional[dict],
+ service_name: str = "neon_users_service"):
+ MQConnector.__init__(self, config, service_name)
+ self.vhost = '/neon_users'
+ module_config = (config or Configuration()).get('neon_users_service',
+ {})
+ self.service = NeonUsersService(module_config)
+
+ def parse_mq_request(self, mq_req: dict) -> dict:
+ """
+ Handle a request to interact with the user database.
+
+ Create: Accepts a new User object and adds it to the database
+ Read: Accepts a Username or User ID and either an Access Token or
+ Password. If the authenticating user is not the same as the requested
+ user, then sensitive authentication information will be redacted from
+ the returned object.
+ Update: Updates the database with the supplied User. If
+ `auth_username` and `auth_password` are supplied, they will be used
+ to determine permissions for this transaction, otherwise permissions
+ will be read for the user being updated. A user may modify their own
+ configuration (except permissions) and any user with a diana role of
+ `ADMIN` or higher may modify other users.
+ Delete: Deletes a User from the database. The request object must match
+ the database entry exactly, so no additional validation is required.
+ """
+ mq_req = UserDbRequest(**mq_req)
+
+ try:
+ if isinstance(mq_req, CreateUserRequest):
+ user = self.service.create_user(mq_req.user)
+ elif isinstance(mq_req, ReadUserRequest):
+ if mq_req.user_spec == mq_req.auth_user_spec:
+ user = self.service.read_authenticated_user(mq_req.user_spec,
+ mq_req.password,
+ mq_req.access_token)
+ else:
+ auth_user = self.service.read_authenticated_user(
+ mq_req.auth_user_spec, mq_req.password,
+ mq_req.access_token)
+ if auth_user.permissions.users < AccessRoles.USER:
+ raise PermissionError(f"User {auth_user.username} does "
+ f"not have permission to read "
+ f"other users")
+ user = self.service.read_unauthenticated_user(
+ mq_req.user_spec)
+ elif isinstance(mq_req, UpdateUserRequest):
+ # Get the authenticating user, maybe raising an AuthenticationError
+ auth = self.service.read_authenticated_user(mq_req.auth_username,
+ mq_req.auth_password)
+ if auth.permissions.users < AccessRoles.ADMIN:
+ if auth.user_id != mq_req.user.user_id:
+ raise PermissionError(f"User {auth.username} does not "
+ f"have permission to modify "
+ f"other users")
+ # Do not allow this non-admin to change their permissions
+ mq_req.user.permissions = auth.permissions
+
+ user = self.service.update_user(mq_req.user)
+ elif isinstance(mq_req, DeleteUserRequest):
+ # If the passed User object isn't an exact match, this will fail
+ user = self.service.delete_user(mq_req.user)
+ else:
+ raise RuntimeError(f"Unsupported operation requested: "
+ f"{mq_req}")
+ return {"success": True, "user": user.model_dump()}
+ except UserExistsError:
+ return {"success": False, "error": "User already exists",
+ "code": 409}
+ except UserNotFoundError:
+ return {"success": False, "error": "User does not exist",
+ "code": 404}
+ except UserNotMatchedError:
+ return {"success": False, "error": "Invalid user", "code": 401}
+ except AuthenticationError:
+ return {"success": False, "error": "Invalid username or password",
+ "code": 401}
+ except Exception as e:
+ return {"success": False, "error": repr(e), "code": 500}
+
+ def handle_request(self,
+ channel: pika.channel.Channel,
+ method: pika.spec.Basic.Deliver,
+ _: pika.spec.BasicProperties,
+ body: bytes):
+ """
+ Handles input MQ request objects.
+ @param channel: MQ channel object (pika.channel.Channel)
+ @param method: MQ return method (pika.spec.Basic.Deliver)
+ @param _: MQ properties (pika.spec.BasicProperties)
+ @param body: request body (bytes)
+ """
+ message_id = None
+ try:
+ if not isinstance(body, bytes):
+ raise TypeError(f'Invalid body received, expected bytes string;'
+ f' got: {type(body)}')
+ request = b64_to_dict(body)
+ message_id = request.get("message_id")
+ response = self.parse_mq_request(request)
+ response["message_id"] = message_id
+ data = dict_to_b64(response)
+
+ routing_key = request.get('routing_key', 'neon_users_output')
+ # queue declare is idempotent, just making sure queue exists
+ channel.queue_declare(queue=routing_key)
+
+ channel.basic_publish(
+ exchange='',
+ routing_key=routing_key,
+ body=data,
+ properties=pika.BasicProperties(expiration='1000')
+ )
+ LOG.info(f"Sent response to queue {routing_key}: {response}")
+ channel.basic_ack(method.delivery_tag)
+ except Exception as e:
+ LOG.exception(f"message_id={message_id}: {e}")
+
+ def pre_run(self, **kwargs):
+ self.register_consumer("neon_users_consumer", self.vhost,
+ "neon_users_input", self.handle_request,
+ auto_ack=False)
diff --git a/neon_users_service/service.py b/neon_users_service/service.py
new file mode 100644
index 0000000..e23f649
--- /dev/null
+++ b/neon_users_service/service.py
@@ -0,0 +1,155 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import hashlib
+import re
+
+from copy import copy
+from typing import Optional
+from ovos_config import Configuration
+
+from neon_data_models.models.api.jwt import HanaToken
+from neon_users_service.databases import UserDatabase
+from neon_users_service.exceptions import (ConfigurationError,
+ AuthenticationError,
+ UserNotMatchedError)
+from neon_data_models.models.user import User
+
+
+class NeonUsersService:
+ def __init__(self, config: Optional[dict] = None):
+ self.config = config or Configuration().get("neon_users_service", {})
+ self.database = self.init_database()
+ if not self.database:
+ raise ConfigurationError(f"`{self.config.get('module')}` is not a "
+ f"valid database module.")
+
+ def init_database(self) -> UserDatabase:
+ module = self.config.get("module")
+ module_config = self.config.get(module)
+ if module == "sqlite":
+ from neon_users_service.databases.sqlite import SQLiteUserDatabase
+ return SQLiteUserDatabase(**module_config)
+ elif module == "mongodb":
+ from neon_users_service.databases.mongodb import MongoDbUserDatabase
+ return MongoDbUserDatabase(**module_config)
+ # Other supported databases may be added here
+
+ @staticmethod
+ def _ensure_hashed(password: str) -> str:
+ """
+ Generate the sha-256 hash for an input password to be stored in the
+ database. If the password is already a valid hash string, it will be
+ returned with no changes.
+ @param password: Input password string to be hashed
+ @retruns: A hexadecimal string representation of the sha-256 hash
+ """
+ if re.compile(r"^[a-f0-9]{64}$").match(password):
+ password_hash = password
+ else:
+ password_hash = hashlib.sha256(password.encode("utf-8")).hexdigest()
+ return password_hash
+
+ def create_user(self, user: User) -> User:
+ """
+ Helper to create a new user. Includes a check that the input password
+ hash is valid, replacing string passwords with hashes as necessary.
+ @param user: The user to be created
+ @returns: The user as added to the database
+ """
+ # Create a copy to prevent modifying the input object
+ user = copy(user)
+ user.password_hash = self._ensure_hashed(user.password_hash)
+ return self.database.create_user(user)
+
+ def _read_user(self, user_spec: str, password: Optional[str] = None,
+ auth_token: Optional[HanaToken] = None) -> User:
+ user = self.database.read_user(user_spec)
+ if password and self._ensure_hashed(password) == user.password_hash:
+ return user
+ elif auth_token and any((tok.jti == f"{auth_token.jti}.refresh"
+ for tok in user.tokens)):
+ return user
+ else:
+ user.password_hash = None
+ user.tokens = []
+ return user
+
+ def read_unauthenticated_user(self, user_spec: str) -> User:
+ """
+ Helper to get a user from the database with sensitive data removed.
+ This is what most lookups should return; `authenticate_user` can be
+ used to get an un-redacted User object.
+ @param user_spec: username or user_id to retrieve
+ @returns: Redacted User object with sensitive information removed
+ """
+ return self._read_user(user_spec)
+
+ def read_authenticated_user(self, username: str,
+ password: Optional[str] = None,
+ auth_token: Optional[HanaToken] = None) -> User:
+ """
+ Helper to get a user from the database, only if the requested username
+ and password match a database entry.
+ @param username: The username or user ID to retrieve
+ @param password: The hashed or plaintext password for the username
+ @param auth_token: A valid authentication token for the username
+ @returns: User object from the database if authentication was successful
+ """
+ # This will raise a `UserNotFound` exception if the user doesn't exist
+ if password:
+ user = self._read_user(username, password)
+ elif auth_token:
+ user = self._read_user(username, auth_token=auth_token)
+ else:
+ raise AuthenticationError("No password or token provided")
+ if user.password_hash is None:
+ raise AuthenticationError(f"Invalid password for {username}")
+ return user
+
+ def update_user(self, user: User) -> User:
+ """
+ Helper to update a user. If the supplied user's password is not defined,
+ an `AuthenticationError` will be raised.
+ @param user: The updated user object to update in the database
+ @retruns: User object as it exists in the database, after updating
+ """
+ # Create a copy to prevent modifying the input object
+ user = copy(user)
+ if not user.password_hash:
+ raise ValueError("Supplied user password is empty")
+ if not isinstance(user.tokens, list):
+ raise ValueError("Supplied tokens configuration is not a list")
+ user.password_hash = self._ensure_hashed(user.password_hash)
+ # This will raise a `UserNotFound` exception if the user doesn't exist
+ return self.database.update_user(user)
+
+ def delete_user(self, user: User) -> User:
+ """
+ Helper to remove a user from the database. If the supplied user does not
+ match any database entry, a `UserNotFoundError` will be raised.
+ @param user: The user object to remove from the database
+ @returns: User object removed from the database
+ """
+ db_user = self.database.read_user_by_id(user.user_id)
+ if db_user != user:
+ raise UserNotMatchedError(user)
+ return self.database.delete_user(user.user_id)
+
+ def shutdown(self):
+ """
+ Shutdown the service.
+ """
+ self.database.shutdown()
diff --git a/requirements/mongodb.txt b/requirements/mongodb.txt
new file mode 100644
index 0000000..923342c
--- /dev/null
+++ b/requirements/mongodb.txt
@@ -0,0 +1 @@
+pymongo~=4.0
\ No newline at end of file
diff --git a/requirements/mq.txt b/requirements/mq.txt
new file mode 100644
index 0000000..889bbf0
--- /dev/null
+++ b/requirements/mq.txt
@@ -0,0 +1 @@
+neon-mq-connector~=0.7,>=0.7.2a3
\ No newline at end of file
diff --git a/requirements/requirements.txt b/requirements/requirements.txt
new file mode 100644
index 0000000..9f7b0f7
--- /dev/null
+++ b/requirements/requirements.txt
@@ -0,0 +1,4 @@
+pydantic~=2.0
+ovos-config~=0.1
+ovos-utils~=0.0
+neon-data-models>=0.0.0a4
\ No newline at end of file
diff --git a/requirements/test_requirements.txt b/requirements/test_requirements.txt
new file mode 100644
index 0000000..68e751a
--- /dev/null
+++ b/requirements/test_requirements.txt
@@ -0,0 +1,2 @@
+pytest
+mock
\ No newline at end of file
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..f192a76
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,90 @@
+# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework
+# All trademark and other rights reserved by their respective owners
+# Copyright 2008-2022 Neongecko.com Inc.
+# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds,
+# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo
+# BSD-3 License
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+# 3. Neither the name of the copyright holder nor the names of its
+# contributors may be used to endorse or promote products derived from this
+# software without specific prior written permission.
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR
+# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
+# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
+# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
+# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+from setuptools import setup, find_packages
+from os import getenv, path
+
+BASE_PATH = path.abspath(path.dirname(__file__))
+
+
+def get_requirements(requirements_filename: str):
+ requirements_file = path.join(BASE_PATH, "requirements", requirements_filename)
+ with open(requirements_file, 'r', encoding='utf-8') as r:
+ requirements = r.readlines()
+ requirements = [r.strip() for r in requirements if r.strip() and not r.strip().startswith("#")]
+
+ for i in range(0, len(requirements)):
+ r = requirements[i]
+ if "@" in r:
+ parts = [p.lower() if p.strip().startswith("git+http") else p for p in r.split('@')]
+ r = "@".join(parts)
+ if getenv("GITHUB_TOKEN"):
+ if "github.com" in r:
+ requirements[i] = r.replace("github.com", f"{getenv('GITHUB_TOKEN')}@github.com")
+ return requirements
+
+
+with open(path.join(BASE_PATH, "README.md"), "r") as f:
+ long_description = f.read()
+
+with open(path.join(BASE_PATH, "version.py"), "r", encoding="utf-8") as v:
+ for line in v.readlines():
+ if line.startswith("__version__"):
+ if '"' in line:
+ version = line.split('"')[1]
+ else:
+ version = line.split("'")[1]
+
+setup(
+ name='neon-users-service',
+ version=version,
+ description='Neon User Management Module',
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ url='https://github.com/NeonGeckoCom/neon-users-service',
+ author='Neongecko',
+ author_email='developers@neon.ai',
+ license='AGPL-3.0-only',
+ packages=find_packages(),
+ package_data={'neon_users_service': ['default_config.yaml']},
+ include_package_data=True,
+ install_requires=get_requirements("requirements.txt"),
+ extras_require={"test": get_requirements("test_requirements.txt"),
+ "mq": get_requirements("mq.txt"),
+ "mongodb": get_requirements("mongodb.txt")},
+ zip_safe=True,
+ classifiers=[
+ 'Intended Audience :: Developers',
+ 'Programming Language :: Python :: 3',
+ ],
+ entry_points={
+ 'console_scripts': [
+ 'neon_users_service=neon_users_service.__main__:main'
+ ]
+ }
+)
diff --git a/tests/test_databases.py b/tests/test_databases.py
new file mode 100644
index 0000000..982776d
--- /dev/null
+++ b/tests/test_databases.py
@@ -0,0 +1,205 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import json
+
+from os import remove, environ
+from os.path import join, dirname, isfile
+from time import time
+from typing import Optional
+from unittest import TestCase
+from uuid import uuid4
+
+from neon_users_service.databases.sqlite import SQLiteUserDatabase
+from neon_users_service.exceptions import UserExistsError, UserNotFoundError
+from neon_data_models.models.user import User
+from neon_data_models.enum import AccessRoles
+
+
+class TestSqlite(TestCase):
+ test_db_file = join(dirname(__file__), 'test_db.sqlite')
+ database: Optional[SQLiteUserDatabase] = None
+
+ def setUp(self):
+ if isfile(self.test_db_file):
+ remove(self.test_db_file)
+ self.database = SQLiteUserDatabase(self.test_db_file)
+
+ def tearDown(self):
+ self.database.shutdown()
+
+ def test_create_user(self):
+ # Create a unique user
+ create_time = round(time())
+ user = self.database.create_user(User(username="test_user",
+ password_hash="test123"))
+ self.assertEqual(user.username, "test_user")
+ self.assertEqual(user.password_hash, "test123")
+ self.assertIsInstance(user.user_id, str)
+ self.assertAlmostEqual(user.created_timestamp, create_time, delta=2)
+
+ # Fail on an existing username
+ with self.assertRaises(UserExistsError):
+ self.database.create_user(User(username=user.username,
+ password_hash="test"))
+ # Fail on an existing user ID
+ with self.assertRaises(UserExistsError):
+ self.database.create_user(User(username="new_user",
+ password_hash="test",
+ user_id=user.user_id))
+
+ # Second user
+ create_time = round(time())
+ user2 = self.database.create_user(User(username="test_user_1",
+ password_hash="test"))
+ self.assertNotEqual(user, user2)
+ self.assertAlmostEqual(user2.created_timestamp, create_time, delta=2)
+
+ def test_read_user(self):
+ user = self.database.create_user(User(username="test",
+ password_hash="test123"))
+ # Retrieve valid user by user_id and username
+ self.assertEqual(self.database.read_user_by_id(user.user_id), user)
+ self.assertEqual(self.database.read_user_by_username(user.username),
+ user)
+
+ # Retrieve using `read_user` method from base class
+ self.assertEqual(self.database.read_user(user.user_id), user)
+ self.assertEqual(self.database.read_user(user.username), user)
+
+ # Retrieve nonexistent user raises exceptions
+ with self.assertRaises(UserNotFoundError):
+ self.database.read_user_by_id("fake-user-id")
+ with self.assertRaises(UserNotFoundError):
+ self.database.read_user_by_username("fake-user-username")
+
+ def test_update_user(self):
+ create_time = round(time())
+ user = self.database.create_user(User(username="test_user",
+ password_hash="test123"))
+ self.assertEqual(user.username, "test_user")
+ self.assertAlmostEqual(user.created_timestamp, create_time, delta=2)
+
+ # Test Change Username and Setting
+ user.username = "updated_name"
+ user.permissions.node = AccessRoles.ADMIN
+ user.created_timestamp = round(time())
+
+ # Test update
+ user2 = self.database.update_user(user)
+ self.assertEqual(user2.username, "updated_name")
+ self.assertEqual(user2.permissions.node, AccessRoles.ADMIN)
+ # `created_timestamp` is immutable
+ self.assertEqual(user2.created_timestamp, user.created_timestamp)
+ self.assertAlmostEqual(user2.created_timestamp, create_time, delta=2)
+ # old username is no longer in the database
+ with self.assertRaises(UserNotFoundError):
+ self.database.read_user_by_username("test_user")
+ # new username does resolve
+ self.assertEqual(self.database.read_user_by_username("updated_name"),
+ user2)
+ self.assertEqual(self.database.read_user_by_id(user2.user_id), user2)
+
+ def test_delete_user(self):
+ with self.assertRaises(UserNotFoundError):
+ self.database.delete_user("user-id")
+ user = self.database.create_user(User(username="test_delete",
+ password_hash="password"))
+ # Removal requires UID, not just username
+ with self.assertRaises(UserNotFoundError):
+ self.database.delete_user(user.username)
+
+ removed_user = self.database.delete_user(user.user_id)
+ self.assertEqual(user, removed_user)
+
+ with self.assertRaises(UserNotFoundError):
+ self.database.read_user_by_id(user.user_id)
+ with self.assertRaises(UserNotFoundError):
+ self.database.read_user_by_username(user.username)
+
+
+class TestMongoDb(TestCase):
+ test_config = json.loads(environ.get("MONGO_TEST_CONFIG"))
+ test_config['collection_name'] = f"{test_config['collection_name']}{time()}"
+ from neon_users_service.databases.mongodb import MongoDbUserDatabase
+ database = MongoDbUserDatabase(**test_config)
+ test_user = User(username="test_user", password_hash="password")
+
+ def tearDown(self):
+ try:
+ self.database.delete_user(self.test_user.user_id)
+ except UserNotFoundError:
+ pass
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.database.db.drop_collection(cls.test_config['collection_name'])
+ cls.database.shutdown()
+
+ def test_create_user(self):
+ # Create User
+ user = self.database.create_user(self.test_user)
+ self.assertEqual(user, self.test_user)
+
+ # Existing user fails
+ with self.assertRaises(UserExistsError):
+ self.database.create_user(self.test_user)
+
+ def test_read_user(self):
+ user = self.database.create_user(self.test_user)
+
+ by_id = self.database.read_user_by_id(user.user_id)
+ by_name = self.database.read_user_by_username(user.username)
+ self.assertEqual(by_id, user)
+ self.assertEqual(by_name, user)
+
+ # Invalid inputs
+ with self.assertRaises(UserNotFoundError):
+ self.database.read_user_by_id(user.username)
+ with self.assertRaises(UserNotFoundError):
+ self.database.read_user_by_username(user.user_id)
+
+ def test_update_user(self):
+ user = self.database.create_user(self.test_user)
+
+ user.password_hash = "new_password"
+ user.permissions.node = AccessRoles.OWNER
+
+ # Invalid change request
+ fake_time = round(time()) + 10
+ user.created_timestamp = fake_time
+
+ updated = self.database.update_user(user)
+ self.assertNotEqual(user.created_timestamp,
+ updated.created_timestamp, f"fake={fake_time}")
+ self.assertEqual(self.test_user.created_timestamp,
+ updated.created_timestamp)
+
+ self.assertEqual(updated.password_hash, user.password_hash)
+ self.assertEqual(updated.permissions.node, AccessRoles.OWNER)
+
+ # Invalid update request (user not exists
+ user.user_id = str(uuid4())
+ with self.assertRaises(UserNotFoundError):
+ self.database.update_user(user)
+
+ def test_delete_user(self):
+ user = self.database.create_user(self.test_user)
+
+ # Delete valid user
+ self.assertEqual(user, self.database.delete_user(user.user_id))
+
+ with self.assertRaises(UserNotFoundError):
+ self.database.delete_user(user.user_id)
diff --git a/tests/test_service.py b/tests/test_service.py
new file mode 100644
index 0000000..f0e5af1
--- /dev/null
+++ b/tests/test_service.py
@@ -0,0 +1,163 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import hashlib
+import os
+from unittest import TestCase
+from os.path import join, dirname, isfile
+
+from neon_users_service.databases import UserDatabase
+from neon_users_service.databases.sqlite import SQLiteUserDatabase
+from neon_users_service.exceptions import ConfigurationError, AuthenticationError, UserNotFoundError, \
+ UserNotMatchedError
+from neon_data_models.models.user import User
+from neon_users_service.service import NeonUsersService
+
+
+class TestUsersService(TestCase):
+ test_db_path = join(dirname(__file__), 'test_db.sqlite')
+ test_config = {"module": "sqlite",
+ "sqlite": {"db_path": test_db_path}}
+
+ def setUp(self):
+ if isfile(self.test_db_path):
+ os.remove(self.test_db_path)
+
+ def test_create_service(self):
+ # Create with default config
+ service = NeonUsersService()
+ self.assertIsNotNone(service.config)
+ self.assertIsInstance(service.database, UserDatabase)
+ service.shutdown()
+
+ # Create with valid passed configuration
+ service = NeonUsersService(self.test_config)
+ self.assertIsInstance(service.database, SQLiteUserDatabase)
+ self.assertTrue(isfile(self.test_db_path))
+ service.shutdown()
+
+ # Create with invalid configuration
+ with self.assertRaises(ConfigurationError):
+ NeonUsersService({"module": None})
+
+ def test_create_user(self):
+ service = NeonUsersService(self.test_config)
+ string_password = "super secret password"
+ hashed_password = hashlib.sha256(string_password.encode()).hexdigest()
+ user_1 = service.create_user(User(username="user_1",
+ password_hash=hashed_password))
+ input_user_2 = User(username="user_2", password_hash=string_password)
+ user_2 = service.create_user(input_user_2)
+ self.assertEqual(user_1.password_hash, hashed_password)
+ self.assertEqual(user_2.password_hash, hashed_password)
+ # The input object should not be modified
+ self.assertNotEqual(user_2, input_user_2)
+ service.shutdown()
+
+ def test_read_authenticated_user(self):
+ service = NeonUsersService(self.test_config)
+ string_password = "super secret password"
+ hashed_password = hashlib.sha256(string_password.encode()).hexdigest()
+ user_1 = service.create_user(User(username="user",
+ password_hash=hashed_password))
+ auth_1 = service.read_authenticated_user("user", string_password)
+ self.assertEqual(auth_1, user_1)
+ auth_2 = service.read_authenticated_user("user", hashed_password)
+ self.assertEqual(auth_2, user_1)
+
+ with self.assertRaises(AuthenticationError):
+ service.read_authenticated_user("user", "bad password")
+
+ with self.assertRaises(UserNotFoundError):
+ service.read_authenticated_user("user_1", hashed_password)
+ service.shutdown()
+
+ def test_read_unauthenticated_user(self):
+ service = NeonUsersService(self.test_config)
+ user_1 = service.create_user(User(username="user",
+ password_hash="test"))
+ read_user = service.read_unauthenticated_user("user")
+ self.assertEqual(read_user, service.read_unauthenticated_user(user_1.user_id))
+ self.assertIsNone(read_user.password_hash)
+ self.assertEqual(read_user.tokens, [])
+ read_user.password_hash = user_1.password_hash
+ read_user.tokens = user_1.tokens
+ self.assertEqual(user_1, read_user)
+
+ with self.assertRaises(UserNotFoundError):
+ service.read_unauthenticated_user("not_a_user")
+ service.shutdown()
+
+ def test_update_user(self):
+ service = NeonUsersService(self.test_config)
+ user_1 = service.create_user(User(username="user",
+ password_hash="test"))
+
+ # Valid update
+ user_1.username = "new_username"
+ updated_user = service.update_user(user_1)
+ self.assertEqual(updated_user, user_1)
+
+ # Invalid password values
+ updated_user.password_hash = None
+ with self.assertRaises(ValueError):
+ service.update_user(updated_user)
+ updated_user.password_hash = ""
+ with self.assertRaises(ValueError):
+ service.update_user(updated_user)
+
+ # Valid password values
+ updated_user.password_hash = user_1.password_hash
+ updated_user = service.update_user(updated_user)
+ self.assertEqual(updated_user.password_hash, user_1.password_hash)
+
+ # Input plaintext passwords should be hashed
+ updated_user.password_hash = "test"
+ new_updated_user = service.update_user(updated_user)
+ self.assertNotEqual(updated_user.password_hash,
+ new_updated_user.password_hash)
+ self.assertEqual(new_updated_user.password_hash, user_1.password_hash)
+
+ # Invalid token values
+ updated_user.tokens = None
+ with self.assertRaises(ValueError):
+ service.update_user(updated_user)
+
+ service.shutdown()
+
+ def test_delete_user(self):
+ service = NeonUsersService(self.test_config)
+ user_1 = service.create_user(User(username="user",
+ password_hash="test"))
+ invalid_user = User(username="user", password_hash="test")
+ incomplete_user = service.read_unauthenticated_user(user_1.user_id)
+
+ # Requested user not in the database
+ with self.assertRaises(UserNotFoundError):
+ service.delete_user(invalid_user)
+
+ # Request missing sensitive user information
+ with self.assertRaises(UserNotMatchedError):
+ service.delete_user(incomplete_user)
+
+ # Successful deletion
+ deleted = service.delete_user(user_1)
+ self.assertEqual(deleted, user_1)
+
+ # User already removed
+ with self.assertRaises(UserNotFoundError):
+ service.read_unauthenticated_user(user_1.user_id)
+
+ service.shutdown()
diff --git a/version.py b/version.py
new file mode 100644
index 0000000..f165e66
--- /dev/null
+++ b/version.py
@@ -0,0 +1,16 @@
+# Copyright (C) 2024 Neongecko.com Inc.
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+__version__ = "0.0.1a0"