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"