Repository: CentOS/duffy (language: python)

Issue:
Authenticate endpoint access
This needs #136.

# Stories

As an admin of Duffy,
I want that the API authenticates its users,
so privileged operations are limited to admins.

As a normal tenant user of Duffy,
I want that the API authenticates requests,
so that only I can request resources on my behalf.

# Acceptance Criteria

- [x] A way exists to define an API key/token per tenant
- [x] API endpoints check token validity and levels of access before performing work
- [x] API endpoints return suitable status codes and error information if an activity that needs authorization is attempted with the wrong or a missing token

# Background

- https://fastapi.tiangolo.com/tutorial/security/first-steps/


Discussion / hints:
@nphilipp, thanks for refining the card. Some design-based questions, I have that I would love to have answers for - before I set out ~on the long and arduous adventure~ to implement authentication.

- Some HTTP methods (like GET etc) do not mask parameters at all while some HTTP methods (like POST etc) mask parameters in Base 64 format (or encoding of such kind). Do we make it mandatory for the clients to hash API keys or tokens before sending those to the Duffy API?

- Do we create specially-defined but empty projects to house API tokens for admin purposes? or Do we simply store it as one of the restricted secrets of the project/repository?

I am likely to come up with more questions but getting answers of these should help me get started. I understand that some of these questions are more of the opinionated kind than the one having defined right or wrong answers.
As we discussed:

- API Tokens are usually transferred in the headers of a request which aren't logged usually (which is why secrets don't belong into GET/CGI parameters). I strongly assume Duffy will sit behind an HTTP proxy when used in production, so we needn't worry about spilling secrets while in transit either. Some assumptions here, but I think they're sound! :grin: 
- We'll rename "projects" to "tenants", that way we can lump admins together with project users and not stretch the metaphor too far (#136).
Now that https://github.com/CentOS/duffy/pull/138 and https://github.com/CentOS/duffy/pull/142, this ticket can be safely stated to be in an "unblocked" state, unless someone comes up with some more design-based questions that need answering before a pull request addressing this PR can be merged.

Let me help with those questions.

1. For normal tenants - 
   1. Which endpoints **should be accessible** to the tenants and which endpoints **should not be**?
   2. Which endpoints **make it necessary to require** an API key, which ones can have **different outputs on the basis of its presence or absence** and which ones **absolutely do not require** an API key - for the tenants to operate?
2. For admin tenants - 
   1. Which endpoints **should be accessible** to the tenants and which endpoints **should not be**?
   2. Which endpoints **make it necessary to require** an API key, which ones can have **different outputs on the basis of its presence or absence** and which ones **absolutely do not require** an API key - for the tenants to operate?

Fix patch:
diff --git a/duffy/api_models/tenant.py b/duffy/api_models/tenant.py
index 18e575b0..d35e8d78 100644
--- a/duffy/api_models/tenant.py
+++ b/duffy/api_models/tenant.py
@@ -1,7 +1,7 @@
 from abc import ABC
 from typing import List, Optional
 
-from pydantic import BaseModel
+from pydantic import UUID4, BaseModel
 
 from .common import APIResult, CreatableMixin, RetirableMixin
 
@@ -18,7 +18,7 @@ class Config:
 
 
 class TenantCreateModel(TenantBase):
-    pass
+    api_key: UUID4
 
 
 class TenantModel(TenantBase, CreatableMixin, RetirableMixin):
diff --git a/duffy/app/controllers/tenant.py b/duffy/app/controllers/tenant.py
index 58a9ba2c..77092404 100644
--- a/duffy/app/controllers/tenant.py
+++ b/duffy/app/controllers/tenant.py
@@ -40,14 +40,15 @@ async def get_tenant(id: int):
     return {"action": "get", "tenant": tenant}
 
 
-# http --json post http://localhost:8080/api/v1/tenants name="A tenant with a unique name"
+# http --json post http://localhost:8080/api/v1/tenants name="Unique name"
 @router.post("", status_code=HTTP_201_CREATED, response_model=TenantResult, tags=["tenants"])
 async def create_tenant(data: TenantCreateModel):
     """
     Create a tenant with the specified **name**
     """
-    tenant = Tenant(name=data.name, is_admin=data.is_admin, ssh_key=data.ssh_key)
-
+    tenant = Tenant(
+        name=data.name, is_admin=data.is_admin, api_key=data.api_key, ssh_key=data.ssh_key
+    )
     DBSession.add(tenant)
     try:
         await DBSession.commit()
diff --git a/duffy/database/model/tenant.py b/duffy/database/model/tenant.py
index 23e4c0c9..76aa6132 100644
--- a/duffy/database/model/tenant.py
+++ b/duffy/database/model/tenant.py
@@ -1,4 +1,8 @@
-from sqlalchemy import Boolean, Column, Integer, UnicodeText, text
+import uuid
+
+import bcrypt
+from sqlalchemy import Boolean, Column, Integer, Text, UnicodeText, text
+from sqlalchemy.ext.hybrid import hybrid_property
 
 from .. import Base
 from ..util import CreatableMixin, RetirableMixin
@@ -11,3 +15,17 @@ class Tenant(Base, CreatableMixin, RetirableMixin):
     name = Column(UnicodeText, unique=True, nullable=False)
     is_admin = Column(Boolean, nullable=False, default=False, server_default=text("FALSE"))
     ssh_key = Column(UnicodeText, nullable=False)
+    _api_key = Column("api_key", Text, nullable=False)
+
+    @hybrid_property
+    def api_key(self):
+        return self._api_key
+
+    @api_key.setter
+    def api_key(self, key: uuid.UUID):
+        salt = bcrypt.gensalt()
+        hashed_key = bcrypt.hashpw(str(key).encode("ascii"), salt)
+        self._api_key = hashed_key.decode("ascii")
+
+    def validate_api_key(self, key: uuid.UUID):
+        return bcrypt.checkpw(str(key).encode("ascii"), self._api_key.encode("ascii"))
diff --git a/poetry.lock b/poetry.lock
index 0fdd464d..ff315f9c 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -124,6 +124,22 @@ python-versions = ">=2.7"
 docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
 testing = ["pytest (>=4.6)", "pytest-flake8", "pytest-cov", "pytest-black (>=0.3.7)", "pytest-mypy", "pytest-checkdocs (>=2.4)", "pytest-enabler (>=1.0.1)"]
 
+[[package]]
+name = "bcrypt"
+version = "3.2.0"
+description = "Modern password hashing for your software and your servers"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.dependencies]
+cffi = ">=1.1"
+six = ">=1.4.1"
+
+[package.extras]
+tests = ["pytest (>=3.2.1,!=3.3.0)"]
+typecheck = ["mypy"]
+
 [[package]]
 name = "billiard"
 version = "3.6.4.0"
@@ -223,7 +239,7 @@ python-versions = "*"
 name = "cffi"
 version = "1.15.0"
 description = "Foreign Function Interface for Python calling C code."
-category = "dev"
+category = "main"
 optional = false
 python-versions = "*"
 
@@ -879,7 +895,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 name = "pycparser"
 version = "2.21"
 description = "C parser in Python"
-category = "dev"
+category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
@@ -1354,7 +1370,7 @@ postgresql = ["psycopg2"]
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.8"
-content-hash = "144c193fcd464551cbef2f82f441c6cee9a88f8c5473986da3aa65e7c36b3b11"
+content-hash = "00bd724abf201d091865e661008d4b0c8d5748f731e6ee12a8c34de1502c758f"
 
 [metadata.files]
 aiosqlite = [
@@ -1401,6 +1417,15 @@ backcall = [
     {file = "backports.entry_points_selectable-1.1.0-py2.py3-none-any.whl", hash = "sha256:a6d9a871cde5e15b4c4a53e3d43ba890cc6861ec1332c9c2428c92f977192acc"},
     {file = "backports.entry_points_selectable-1.1.0.tar.gz", hash = "sha256:988468260ec1c196dab6ae1149260e2f5472c9110334e5d51adcb77867361f6a"},
 ]
+bcrypt = [
+    {file = "bcrypt-3.2.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:c95d4cbebffafcdd28bd28bb4e25b31c50f6da605c81ffd9ad8a3d1b2ab7b1b6"},
+    {file = "bcrypt-3.2.0-cp36-abi3-manylinux1_x86_64.whl", hash = "sha256:63d4e3ff96188e5898779b6057878fecf3f11cfe6ec3b313ea09955d587ec7a7"},
+    {file = "bcrypt-3.2.0-cp36-abi3-manylinux2010_x86_64.whl", hash = "sha256:cd1ea2ff3038509ea95f687256c46b79f5fc382ad0aa3664d200047546d511d1"},
+    {file = "bcrypt-3.2.0-cp36-abi3-manylinux2014_aarch64.whl", hash = "sha256:cdcdcb3972027f83fe24a48b1e90ea4b584d35f1cc279d76de6fc4b13376239d"},
+    {file = "bcrypt-3.2.0-cp36-abi3-win32.whl", hash = "sha256:a67fb841b35c28a59cebed05fbd3e80eea26e6d75851f0574a9273c80f3e9b55"},
+    {file = "bcrypt-3.2.0-cp36-abi3-win_amd64.whl", hash = "sha256:81fec756feff5b6818ea7ab031205e1d323d8943d237303baca2c5f9c7846f34"},
+    {file = "bcrypt-3.2.0.tar.gz", hash = "sha256:5b93c1726e50a93a033c36e5ca7fdcd29a5c7395af50a6892f5d9e7c6cfbfb29"},
+]
 billiard = [
     {file = "billiard-3.6.4.0-py3-none-any.whl", hash = "sha256:87103ea78fa6ab4d5c751c4909bcff74617d985de7fa8b672cf8618afd5a875b"},
     {file = "billiard-3.6.4.0.tar.gz", hash = "sha256:299de5a8da28a783d51b197d496bef4f1595dd023a93a4f59dde1886ae905547"},
@@ -1692,9 +1717,6 @@ markupsafe = [
     {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad"},
     {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d"},
     {file = "MarkupSafe-2.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a"},
-    {file = "MarkupSafe-2.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a"},
     {file = "MarkupSafe-2.0.1-cp310-cp310-win32.whl", hash = "sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28"},
     {file = "MarkupSafe-2.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51"},
@@ -1706,9 +1728,6 @@ markupsafe = [
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f"},
-    {file = "MarkupSafe-2.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-win32.whl", hash = "sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d"},
     {file = "MarkupSafe-2.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567"},
@@ -1720,9 +1739,6 @@ markupsafe = [
     {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9"},
-    {file = "MarkupSafe-2.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-win32.whl", hash = "sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415"},
     {file = "MarkupSafe-2.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9"},
@@ -1735,9 +1751,6 @@ markupsafe = [
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194"},
-    {file = "MarkupSafe-2.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-win32.whl", hash = "sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64"},
     {file = "MarkupSafe-2.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26"},
@@ -1750,9 +1763,6 @@ markupsafe = [
     {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e"},
-    {file = "MarkupSafe-2.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-win32.whl", hash = "sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74"},
     {file = "MarkupSafe-2.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8"},
     {file = "MarkupSafe-2.0.1.tar.gz", hash = "sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a"},
@@ -2002,11 +2012,6 @@ regex = [
     {file = "regex-2021.11.10-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:30ab804ea73972049b7a2a5c62d97687d69b5a60a67adca07eb73a0ddbc9e29f"},
     {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68a067c11463de2a37157930d8b153005085e42bcb7ad9ca562d77ba7d1404e0"},
     {file = "regex-2021.11.10-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:162abfd74e88001d20cb73ceaffbfe601469923e875caf9118333b1a4aaafdc4"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9ed0b1e5e0759d6b7f8e2f143894b2a7f3edd313f38cf44e1e15d360e11749b"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:473e67837f786404570eae33c3b64a4b9635ae9f00145250851a1292f484c063"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2fee3ed82a011184807d2127f1733b4f6b2ff6ec7151d83ef3477f3b96a13d03"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:d5fd67df77bab0d3f4ea1d7afca9ef15c2ee35dfb348c7b57ffb9782a6e4db6e"},
-    {file = "regex-2021.11.10-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5d408a642a5484b9b4d11dea15a489ea0928c7e410c7525cd892f4d04f2f617b"},
     {file = "regex-2021.11.10-cp310-cp310-win32.whl", hash = "sha256:98ba568e8ae26beb726aeea2273053c717641933836568c2a0278a84987b2a1a"},
     {file = "regex-2021.11.10-cp310-cp310-win_amd64.whl", hash = "sha256:780b48456a0f0ba4d390e8b5f7c661fdd218934388cde1a974010a965e200e12"},
     {file = "regex-2021.11.10-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:dba70f30fd81f8ce6d32ddeef37d91c8948e5d5a4c63242d16a2b2df8143aafc"},
@@ -2016,11 +2021,6 @@ regex = [
     {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5537f71b6d646f7f5f340562ec4c77b6e1c915f8baae822ea0b7e46c1f09b733"},
     {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed2e07c6a26ed4bea91b897ee2b0835c21716d9a469a96c3e878dc5f8c55bb23"},
     {file = "regex-2021.11.10-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ca5f18a75e1256ce07494e245cdb146f5a9267d3c702ebf9b65c7f8bd843431e"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:74cbeac0451f27d4f50e6e8a8f3a52ca074b5e2da9f7b505c4201a57a8ed6286"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:3598893bde43091ee5ca0a6ad20f08a0435e93a69255eeb5f81b85e81e329264"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:50a7ddf3d131dc5633dccdb51417e2d1910d25cbcf842115a3a5893509140a3a"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:61600a7ca4bcf78a96a68a27c2ae9389763b5b94b63943d5158f2a377e09d29a"},
-    {file = "regex-2021.11.10-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:563d5f9354e15e048465061509403f68424fef37d5add3064038c2511c8f5e00"},
     {file = "regex-2021.11.10-cp36-cp36m-win32.whl", hash = "sha256:93a5051fcf5fad72de73b96f07d30bc29665697fb8ecdfbc474f3452c78adcf4"},
     {file = "regex-2021.11.10-cp36-cp36m-win_amd64.whl", hash = "sha256:b483c9d00a565633c87abd0aaf27eb5016de23fed952e054ecc19ce32f6a9e7e"},
     {file = "regex-2021.11.10-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff55f3ce50a3ff63ec8e2a8d3dd924f1941b250b0aac3d3d42b687eeff07a8e"},
@@ -2030,11 +2030,6 @@ regex = [
     {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5ca078bb666c4a9d1287a379fe617a6dccd18c3e8a7e6c7e1eb8974330c626a"},
     {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dd33eb9bdcfbabab3459c9ee651d94c842bc8a05fabc95edf4ee0c15a072495e"},
     {file = "regex-2021.11.10-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:05b7d6d7e64efe309972adab77fc2af8907bb93217ec60aa9fe12a0dad35874f"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:42b50fa6666b0d50c30a990527127334d6b96dd969011e843e726a64011485da"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6e1d2cc79e8dae442b3fa4a26c5794428b98f81389af90623ffcc650ce9f6732"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:0416f7399e918c4b0e074a0f66e5191077ee2ca32a0f99d4c187a62beb47aa05"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:ce298e3d0c65bd03fa65ffcc6db0e2b578e8f626d468db64fdf8457731052942"},
-    {file = "regex-2021.11.10-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:dc07f021ee80510f3cd3af2cad5b6a3b3a10b057521d9e6aaeb621730d320c5a"},
     {file = "regex-2021.11.10-cp37-cp37m-win32.whl", hash = "sha256:e71255ba42567d34a13c03968736c5d39bb4a97ce98188fafb27ce981115beec"},
     {file = "regex-2021.11.10-cp37-cp37m-win_amd64.whl", hash = "sha256:07856afef5ffcc052e7eccf3213317fbb94e4a5cd8177a2caa69c980657b3cb4"},
     {file = "regex-2021.11.10-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba05430e819e58544e840a68b03b28b6d328aff2e41579037e8bab7653b37d83"},
@@ -2045,11 +2040,6 @@ regex = [
     {file = "regex-2021.11.10-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85bfa6a5413be0ee6c5c4a663668a2cad2cbecdee367630d097d7823041bdeec"},
     {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f23222527b307970e383433daec128d769ff778d9b29343fb3496472dc20dabe"},
     {file = "regex-2021.11.10-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:da1a90c1ddb7531b1d5ff1e171b4ee61f6345119be7351104b67ff413843fe94"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:f5be7805e53dafe94d295399cfbe5227f39995a997f4fd8539bf3cbdc8f47ca8"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a955b747d620a50408b7fdf948e04359d6e762ff8a85f5775d907ceced715129"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:139a23d1f5d30db2cc6c7fd9c6d6497872a672db22c4ae1910be22d4f4b2068a"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ca49e1ab99593438b204e00f3970e7a5f70d045267051dfa6b5f4304fcfa1dbf"},
-    {file = "regex-2021.11.10-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:96fc32c16ea6d60d3ca7f63397bff5c75c5a562f7db6dec7d412f7c4d2e78ec0"},
     {file = "regex-2021.11.10-cp38-cp38-win32.whl", hash = "sha256:0617383e2fe465732af4509e61648b77cbe3aee68b6ac8c0b6fe934db90be5cc"},
     {file = "regex-2021.11.10-cp38-cp38-win_amd64.whl", hash = "sha256:a3feefd5e95871872673b08636f96b61ebef62971eab044f5124fb4dea39919d"},
     {file = "regex-2021.11.10-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f7f325be2804246a75a4f45c72d4ce80d2443ab815063cdf70ee8fb2ca59ee1b"},
@@ -2060,11 +2050,6 @@ regex = [
     {file = "regex-2021.11.10-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:962b9a917dd7ceacbe5cd424556914cb0d636001e393b43dc886ba31d2a1e449"},
     {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fa8c626d6441e2d04b6ee703ef2d1e17608ad44c7cb75258c09dd42bacdfc64b"},
     {file = "regex-2021.11.10-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:3c5fb32cc6077abad3bbf0323067636d93307c9fa93e072771cf9a64d1c0f3ef"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:cd410a1cbb2d297c67d8521759ab2ee3f1d66206d2e4328502a487589a2cb21b"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e6096b0688e6e14af6a1b10eaad86b4ff17935c49aa774eac7c95a57a4e8c296"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:529801a0d58809b60b3531ee804d3e3be4b412c94b5d267daa3de7fadef00f49"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f594b96fe2e0821d026365f72ac7b4f0b487487fb3d4aaf10dd9d97d88a9737"},
-    {file = "regex-2021.11.10-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2409b5c9cef7054dde93a9803156b411b677affc84fca69e908b1cb2c540025d"},
     {file = "regex-2021.11.10-cp39-cp39-win32.whl", hash = "sha256:3b5df18db1fccd66de15aa59c41e4f853b5df7550723d26aa6cb7f40e5d9da5a"},
     {file = "regex-2021.11.10-cp39-cp39-win_amd64.whl", hash = "sha256:83ee89483672b11f8952b158640d0c0ff02dc43d9cb1b70c1564b49abe92ce29"},
     {file = "regex-2021.11.10.tar.gz", hash = "sha256:f341ee2df0999bfdf7a95e448075effe0db212a59387de1a70690e4acb03d4c6"},
diff --git a/pyproject.toml b/pyproject.toml
index caf45e96..8cdcad60 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,6 +16,7 @@ alembic = "^1.7.5"
 ipython = {version = "^7.29.0", optional = true}
 psycopg2 = {version = "^2.9.2", optional = true}
 celery = {version = "^5.2.1", extras = ["redis"]}
+bcrypt = "^3.2.0"
 
 [tool.poetry.dev-dependencies]
 black = "^21.9b0"


Test patch:
diff --git a/tests/app/controllers/__init__.py b/tests/app/controllers/__init__.py
index ed6e59ad..1477f474 100644
--- a/tests/app/controllers/__init__.py
+++ b/tests/app/controllers/__init__.py
@@ -18,6 +18,9 @@ class BaseTestController:
                 the API. Values can be tuples of other controller test
                 classes and an item name to create such objects and use
                 the respective items in the result.
+        no_response_attrs:
+                A sequence of attribute names which are not returned in
+                a response from the API endpoint.
         unique: Whether or not objects with the same attributes can
                 exist more than once. If it is a string, check whether
                 it is in the error detail (case-insensitively). If it is
@@ -63,6 +66,7 @@ async def test_method(client):
     name = None
     path = None
     attrs = {}
+    no_response_attrs = ()
     unique = False
 
     @property
@@ -95,13 +99,17 @@ async def _create_obj(cls, client, attrs: dict = None, merge_cls_attrs: bool = T
         return response
 
     @classmethod
-    def _verify_item(cls, item, attrs=None):
+    def _verify_item(cls, item, attrs=None, no_response_attrs=None):
         if not attrs:
             attrs = cls.attrs
+        if not no_response_attrs:
+            no_response_attrs = cls.no_response_attrs
         # The `id` attribute of any created object must be 1 because no objects exist in the
         # database before running this test, the `db_*_model_initialized` fixtures take care of it.
         assert item["id"] == 1
         for key, value in attrs.items():
+            if key in no_response_attrs:
+                continue
             try:
                 subcls, _ = cls.attrs[key]
             except (TypeError, ValueError):
@@ -126,6 +134,8 @@ def _add_attrs_from_response(cls, response):
 
         retval = {}
         for name, value in cls.attrs.items():
+            if name in cls.no_response_attrs:
+                continue
             try:
                 subcls, item = value
             except (TypeError, ValueError):
diff --git a/tests/app/controllers/test_tenant.py b/tests/app/controllers/test_tenant.py
index 6b0c22b8..b28c4b2d 100644
--- a/tests/app/controllers/test_tenant.py
+++ b/tests/app/controllers/test_tenant.py
@@ -1,3 +1,5 @@
+from uuid import uuid4
+
 from starlette.status import HTTP_201_CREATED
 
 from . import BaseTestController
@@ -9,8 +11,10 @@ class TestTenant(BaseTestController):
     path = "/api/v1/tenants"
     attrs = {
         "name": "Some Honky Tenant!",
+        "api_key": str(uuid4()),
         "ssh_key": "With a honky SSH key!",
     }
+    no_response_attrs = ("api_key",)
     unique = "unique"
 
     async def test_with_is_admin_set(self, client):
diff --git a/tests/database/test_model.py b/tests/database/test_model.py
index 3978d0db..f7424762 100644
--- a/tests/database/test_model.py
+++ b/tests/database/test_model.py
@@ -1,4 +1,5 @@
 from typing import Any, Dict
+from uuid import uuid4
 
 import pytest
 from sqlalchemy import select
@@ -10,6 +11,7 @@
 class ModelTestBase:
     klass = None
     attrs = {}
+    no_validate_attrs = ()
 
     def test_create_obj(self, db_sync_obj):
         pass
@@ -18,10 +20,14 @@ def test_query_obj_sync(self, db_sync_obj):
         result = SyncDBSession.execute(select(self.klass))
         obj = result.scalar_one()
         for key, value in self.attrs.items():
+            if key in self.no_validate_attrs:
+                continue
             objvalue = getattr(obj, key)
             if isinstance(objvalue, (int, str)):
                 assert objvalue == value
         for key, value in self._db_obj_get_dependencies().items():
+            if key in self.no_validate_attrs:
+                continue
             objvalue = getattr(obj, key)
             if isinstance(objvalue, (int, str)):
                 assert objvalue == value
@@ -38,10 +44,14 @@ async def test_query_obj_async(self, db_async_obj):
         result = await DBSession.execute(select(self.klass).options(selectinload("*")))
         obj = result.scalar_one()
         for key, value in self.attrs.items():
+            if key in self.no_validate_attrs:
+                continue
             objvalue = getattr(obj, key)
             if isinstance(objvalue, (int, str)):
                 assert objvalue == value
         for key, value in self._db_obj_get_dependencies().items():
+            if key in self.no_validate_attrs:
+                continue
             objvalue = getattr(obj, key)
             if isinstance(objvalue, (int, str)):
                 assert objvalue == value
@@ -60,15 +70,28 @@ class TestTenant(ModelTestBase):
     attrs = {
         "name": "My Fancy Tenant",
         "is_admin": False,
+        "api_key": uuid4(),
         "ssh_key": "this is a public ssh key",
     }
+    no_validate_attrs = ("api_key",)
+
+    @pytest.mark.parametrize("wrong_key", (False, True))
+    def test_validate_api_key(self, wrong_key, db_sync_obj):
+        if wrong_key:
+            assert not db_sync_obj.validate_api_key(uuid4())
+        else:
+            assert db_sync_obj.validate_api_key(self.attrs["api_key"])
 
 
 class TestSession(ModelTestBase):
     klass = model.Session
 
     def _db_obj_get_dependencies(self):
-        tenant = model.Tenant(name="My Other Tenant", ssh_key="my other public SSH key")
+        tenant = model.Tenant(
+            name="My Other Tenant",
+            api_key=uuid4(),
+            ssh_key="my other public SSH key",
+        )
         return {"tenant": tenant}
 
 
@@ -117,7 +140,12 @@ class TestSessionNode(ModelTestBase):
     attrs = {"distro_type": "CentOS", "distro_version": "8Stream"}
 
     def _db_obj_get_dependencies(self):
-        tenant = model.Tenant(name="World Domination", is_admin=True, ssh_key="Muahahahaha!")
+        tenant = model.Tenant(
+            name="World Domination",
+            is_admin=True,
+            api_key=uuid4(),
+            ssh_key="Muahahahaha!",
+        )
         session = model.Session(tenant=tenant)
         chassis = model.Chassis(name="Celestion Greenback")
         node = model.SeaMicroNode(**_gen_node_attrs(chassis=chassis))