Repository: CentOS/duffy (language: python)

Issue:
Session workflow
This implements a good deal of #65: accepting specifications of requested nodes when creating sessions and returning the associated nodes with sessions from the API. It doesn't implement a controller for updating sessions (to let tenants set them inactive) yet, though.

Additionally, implement filling a DB with test data (as test fixtures and an extension to the `setup-db` command) and some refinement to tests.

# How to Test

- Delete the database (file) and perhaps recreate it (PostgreSQL)
- Setup the database and fill it with test data, e.g.:
```
(duffy) nils@makake:~/src/fedora-infra/duffy (dev--session-workflow)> duffy -c etc/duffy-psql-config.yaml setup-db --test-data
Creating database schema
Setting up database migrations
Creating test data
Caution! Created tenants with deterministic API keys:
	admin: ae6c10d0-0b13-554c-b976-a05d8a18f0cc
	tenant: a8b9899d-b128-59a1-aa86-754920b7f5ed
Don't use in production!
(duffy) nils@makake:~/src/fedora-infra/duffy (dev--session-workflow)>
```
- Verify the test data is there, e.g.:
```
(duffy) nils@makake:~/src/fedora-infra/duffy (dev--session-workflow)> psql duffy
psql (13.4)
Type "help" for help.

duffy=> select * from nodes;
          created_at           | retired_at | id |    type    |               hostname                
|    ipaddr    | state  | comment | distro_type | distro_version 
-------------------------------+------------+----+------------+---------------------------------------
+--------------+--------+---------+-------------+----------------
 2021-12-21 12:18:22.036233+01 |            |  1 | opennebula | node-opennebula-small-7.example.net   
| 192.168.1.17 | active |         | fedora      | 35
[ ... ]
 2021-12-21 12:18:22.036233+01 |            | 72 | opennebula | node-opennebula-medium-2.example.net  
| 192.168.2.12 | active |         | centos      | 8Stream
(72 rows)

duffy=>
```
- Start the app server
```
(duffy) nils@makake:~/src/fedora-infra/duffy (dev--session-workflow)> duffy -c etc/duffy-psql-config.yaml serve
 * Starting Duffy...
 * Host address : 127.0.0.1
 * Port number  : 8080
 * Log level    : warning
 * Serving API docs on http://127.0.0.1:8080/docs
```
- Request a session with a certain set of nodes:
```
(duffy) nils@makake:~/src/fedora-infra/duffy (dev--session-workflow)> http --json post http://localhost:8080/api/v1/sessions tenant_id=2 nodes_specs:='[{"quantity": 1, "type": "physical", "distro_type": "fedora", "distro_version": "35"}, {"quantity": 2, "type": "virtual", "flavour": "medium", "distro_type": "fedora", "distro_version": "35"}]'
HTTP/1.1 201 Created
content-length: 769
content-type: application/json
date: Tue, 21 Dec 2021 12:22:12 GMT
server: uvicorn

{
    "action": "post",
    "session": {
        "active": true,
        "created_at": "2021-12-21T11:22:13.362065+00:00",
        "id": 1,
        "nodes": [
            {
                "comment": null,
                "distro_type": "fedora",
                "distro_version": "35",
                "hostname": "node-seamicro-2.example.net",
                "ipaddr": "192.168.0.12",
                "type": "seamicro"
            },
            {
                "comment": null,
                "distro_type": "fedora",
                "distro_version": "35",
                "flavour": "medium",
                "hostname": "node-opennebula-medium-3.example.net",
                "ipaddr": "192.168.2.13",
                "type": "opennebula"
            },
            {
                "comment": null,
                "distro_type": "fedora",
                "distro_version": "35",
                "flavour": "medium",
                "hostname": "node-opennebula-medium-5.example.net",
                "ipaddr": "192.168.2.15",
                "type": "opennebula"
            }
        ],
        "retired_at": null,
        "tenant": {
            "active": true,
            "created_at": "2021-12-21T11:18:22.036233+00:00",
            "id": 2,
            "is_admin": false,
            "name": "tenant",
            "retired_at": null,
            "ssh_key": "Boo!"
        }
    }
}
```
- Request it again to verify that Duffy refuses to hand out more nodes than are available:
```
(duffy) nils@makake:~/src/fedora-infra/duffy (dev--session-workflow)> http --json post http://localhost:8080/api/v1/sessions tenant_id=2 nodes_specs:='[{"quantity": 1, "type": "physical", "distro_type": "fedora", "distro_version": "35"}, {"quantity": 2, "type": "virtual", "flavour": "medium", "distro_type": "fedora", "distro_version": "35"}]'
HTTP/1.1 422 Unprocessable Entity
content-length: 122
content-type: application/json
date: Tue, 21 Dec 2021 12:23:16 GMT
server: uvicorn

{
    "detail": "can't reserve nodes: type=<NodeType.physical: 'physical'> quantity=1 distro_type='fedora' distro_version='35'"
}
```
- Release the reserved nodes by setting the `active` flag to `false` (check `active` and `retired_at` in the response):
```
(duffy) nils@makake:~/src/fedora-infra/duffy (dev--session-workflow)> http --json put http://localhost:8080/api/v1/sessions/1 active:=false
HTTP/1.1 200 OK
content-length: 799
content-type: application/json
date: Tue, 21 Dec 2021 12:25:33 GMT
server: uvicorn

{
    "action": "put",
    "session": {
        "active": false,
        "created_at": "2021-12-21T11:22:13.362065+00:00",
        "id": 1,
        "nodes": [
            {
                "comment": null,
                "distro_type": "fedora",
                "distro_version": "35",
                "hostname": "node-seamicro-2.example.net",
                "ipaddr": "192.168.0.12",
                "type": "seamicro"
            },
            {
                "comment": null,
                "distro_type": "fedora",
                "distro_version": "35",
                "flavour": "medium",
                "hostname": "node-opennebula-medium-3.example.net",
                "ipaddr": "192.168.2.13",
                "type": "opennebula"
            },
            {
                "comment": null,
                "distro_type": "fedora",
                "distro_version": "35",
                "flavour": "medium",
                "hostname": "node-opennebula-medium-5.example.net",
                "ipaddr": "192.168.2.15",
                "type": "opennebula"
            }
        ],
        "retired_at": "2021-12-21T12:25:33.928737+00:00",
        "tenant": {
            "active": true,
            "created_at": "2021-12-21T11:18:22.036233+00:00",
            "id": 2,
            "is_admin": false,
            "name": "tenant",
            "retired_at": null,
            "ssh_key": "Boo!"
        }
    }
}
```



Discussion / hints:

Fix patch:
diff --git a/duffy/api_models/session.py b/duffy/api_models/session.py
index 745a1624..090ecac0 100644
--- a/duffy/api_models/session.py
+++ b/duffy/api_models/session.py
@@ -1,5 +1,5 @@
 from abc import ABC
-from typing import List, Literal, Union
+from typing import List, Literal, Optional, Union
 
 from pydantic import BaseModel, conint
 
@@ -59,7 +59,7 @@ class Config:
 
 
 class SessionCreateModel(SessionBase):
-    tenant_id: int
+    tenant_id: Optional[int]
     nodes_specs: List[Union[PhysicalNodesSpec, VirtualNodesSpec]]
 
 
diff --git a/duffy/app/auth.py b/duffy/app/auth.py
new file mode 100644
index 00000000..5ef04e06
--- /dev/null
+++ b/duffy/app/auth.py
@@ -0,0 +1,46 @@
+from fastapi import Depends, HTTPException, Security
+from fastapi.security import HTTPBasic, HTTPBasicCredentials
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
+
+from ..database.model import Tenant
+from .database import req_db_async_session
+
+
+def _req_tenant_factory(optional: bool = False, **kwargs):
+    """Factory creating FastAPI dependencies for authenticating tenants."""
+    if optional:
+        kwargs["auto_error"] = False
+    security = HTTPBasic(realm="duffy", **kwargs)
+
+    async def _req_tenant(
+        db_async_session: AsyncSession = Depends(req_db_async_session),
+        credentials: HTTPBasicCredentials = Security(security),
+    ):
+        if not credentials:
+            if not optional:
+                raise HTTPException(HTTP_403_FORBIDDEN)
+            else:
+                return None
+
+        tenant_name = credentials.username
+        api_key = credentials.password
+
+        tenant = (
+            await db_async_session.execute(select(Tenant).filter_by(name=tenant_name))
+        ).scalar_one_or_none()
+
+        if not tenant or not tenant.validate_api_key(api_key):
+            raise HTTPException(HTTP_401_UNAUTHORIZED)
+
+        if not tenant.active:
+            raise HTTPException(HTTP_403_FORBIDDEN)
+
+        return tenant
+
+    return _req_tenant
+
+
+req_tenant = _req_tenant_factory()
+req_tenant_optional = _req_tenant_factory(optional=True)
diff --git a/duffy/app/controllers/chassis.py b/duffy/app/controllers/chassis.py
index 8da03f7f..bfb050ad 100644
--- a/duffy/app/controllers/chassis.py
+++ b/duffy/app/controllers/chassis.py
@@ -6,10 +6,16 @@
 from sqlalchemy import select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
-from starlette.status import HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
+from starlette.status import (
+    HTTP_201_CREATED,
+    HTTP_403_FORBIDDEN,
+    HTTP_404_NOT_FOUND,
+    HTTP_409_CONFLICT,
+)
 
 from ...api_models import ChassisCreateModel, ChassisResult, ChassisResultCollection
-from ...database.model import Chassis
+from ...database.model import Chassis, Tenant
+from ..auth import req_tenant
 from ..database import req_db_async_session
 
 router = APIRouter(prefix="/chassis")
@@ -17,7 +23,10 @@
 
 # http get http://localhost:8080/api/v1/chassis
 @router.get("", response_model=ChassisResultCollection, tags=["chassis"])
-async def get_all_chassis(db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def get_all_chassis(
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Return all chassis
     """
@@ -29,7 +38,11 @@ async def get_all_chassis(db_async_session: AsyncSession = Depends(req_db_async_
 
 # http get http://localhost:8080/api/v1/chassis/2
 @router.get("/{id}", response_model=ChassisResult, tags=["chassis"])
-async def get_chassis(id: int, db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def get_chassis(
+    id: int,
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Return the chassis with the specified **ID**
     """
@@ -49,10 +62,14 @@ async def get_chassis(id: int, db_async_session: AsyncSession = Depends(req_db_a
 async def create_chassis(
     data: ChassisCreateModel,
     db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
 ):
     """
     Create a chassis with the specified **name** and optional **description**
     """
+    if not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
     chassis = Chassis(name=data.name, description=data.description)
 
     db_async_session.add(chassis)
@@ -66,10 +83,17 @@ async def create_chassis(
 
 # http delete http://localhost:8080/api/v1/chassis/2
 @router.delete("/{id}", response_model=ChassisResult, tags=["chassis"])
-async def delete_chassis(id: int, db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def delete_chassis(
+    id: int,
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Delete the chassis with the specified **ID**
     """
+    if not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
     chassis = (
         await db_async_session.execute(select(Chassis).filter_by(id=id))
     ).scalar_one_or_none()
diff --git a/duffy/app/controllers/node.py b/duffy/app/controllers/node.py
index feed0090..ef9d1f10 100644
--- a/duffy/app/controllers/node.py
+++ b/duffy/app/controllers/node.py
@@ -5,20 +5,25 @@
 from sqlalchemy.orm import selectinload
 from starlette.status import (
     HTTP_201_CREATED,
+    HTTP_403_FORBIDDEN,
     HTTP_404_NOT_FOUND,
     HTTP_409_CONFLICT,
     HTTP_422_UNPROCESSABLE_ENTITY,
 )
 
 from ...api_models import NodeResult, NodeResultCollection, concrete_node_create_models
-from ...database.model import Chassis, Node, OpenNebulaNode, SeaMicroNode
+from ...database.model import Chassis, Node, OpenNebulaNode, SeaMicroNode, Tenant
+from ..auth import req_tenant
 from ..database import req_db_async_session
 
 router = APIRouter(prefix="/nodes")
 
 
 @router.get("", response_model=NodeResultCollection, tags=["nodes"])
-async def get_all_nodes(db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def get_all_nodes(
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Return all nodes
     """
@@ -29,7 +34,11 @@ async def get_all_nodes(db_async_session: AsyncSession = Depends(req_db_async_se
 
 
 @router.get("/{id}", response_model=NodeResult, tags=["nodes"])
-async def get_node(id: int, db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def get_node(
+    id: int,
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Return the node with the specified **ID**
     """
@@ -47,11 +56,15 @@ async def get_node(id: int, db_async_session: AsyncSession = Depends(req_db_asyn
 async def create_node(
     data: concrete_node_create_models,
     db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
 ):
     """
     Create a node with the specified **type**, **hostname**, **ip address**, **comment** and
     **flavour**
     """
+    if not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
     data.ipaddr = str(data.ipaddr)
 
     args = {
@@ -89,10 +102,17 @@ async def create_node(
 
 
 @router.delete("/{id}", response_model=NodeResult, tags=["nodes"])
-async def delete_node(id: int, db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def delete_node(
+    id: int,
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Deletes the node with the specified **ID**
     """
+    if not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
     node = (
         await db_async_session.execute(select(Node).filter_by(id=id).options(selectinload("*")))
     ).scalar_one_or_none()
diff --git a/duffy/app/controllers/session.py b/duffy/app/controllers/session.py
index 2cbcf2da..c502ee57 100644
--- a/duffy/app/controllers/session.py
+++ b/duffy/app/controllers/session.py
@@ -7,7 +7,12 @@
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
 from sqlalchemy.orm import selectinload
-from starlette.status import HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_422_UNPROCESSABLE_ENTITY
+from starlette.status import (
+    HTTP_201_CREATED,
+    HTTP_403_FORBIDDEN,
+    HTTP_404_NOT_FOUND,
+    HTTP_422_UNPROCESSABLE_ENTITY,
+)
 
 from ...api_models import (
     PhysicalNodesSpec,
@@ -18,6 +23,7 @@
 )
 from ...database.model import PhysicalNode, Session, SessionNode, Tenant, VirtualNode
 from ...database.types import NodeState
+from ..auth import req_tenant
 from ..database import req_db_async_session
 
 router = APIRouter(prefix="/sessions")
@@ -25,7 +31,10 @@
 
 # http get http://localhost:8080/api/v1/sessions
 @router.get("", response_model=SessionResultCollection, tags=["sessions"])
-async def get_all_sessions(db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def get_all_sessions(
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Returns all sessions
     """
@@ -33,13 +42,19 @@ async def get_all_sessions(db_async_session: AsyncSession = Depends(req_db_async
         selectinload(Session.tenant),
         selectinload(Session.session_nodes).selectinload(SessionNode.node),
     )
+    if not tenant.is_admin:
+        query = query.filter_by(tenant=tenant)
     results = await db_async_session.execute(query)
     return {"action": "get", "sessions": results.scalars().all()}
 
 
 # http get http://localhost:8080/api/v1/sessions/2
 @router.get("/{id}", response_model=SessionResult, tags=["sessions"])
-async def get_session(id: int, db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def get_session(
+    id: int,
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Returns a session with the specified **ID**
     """
@@ -55,6 +70,8 @@ async def get_session(id: int, db_async_session: AsyncSession = Depends(req_db_a
     ).scalar_one_or_none()
     if not session:
         raise HTTPException(HTTP_404_NOT_FOUND)
+    if not tenant.is_admin and session.tenant != tenant:
+        raise HTTPException(HTTP_403_FORBIDDEN)
     return {"action": "get", "session": session}
 
 
@@ -63,20 +80,26 @@ async def get_session(id: int, db_async_session: AsyncSession = Depends(req_db_a
 async def create_session(
     data: SessionCreateModel,
     db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
 ):
     """
     Creates a session with the specified **tenant ID**
     """
-    tenant = (
-        await db_async_session.execute(select(Tenant).filter_by(id=data.tenant_id))
-    ).scalar_one_or_none()
-
-    if not tenant:
-        raise HTTPException(
-            HTTP_422_UNPROCESSABLE_ENTITY, f"can't find tenant with id {data.tenant_id}"
-        )
-    elif not tenant.active:
-        raise HTTPException(HTTP_422_UNPROCESSABLE_ENTITY, f"tenant '{tenant.name}' isn't active")
+    if tenant.is_admin and data.tenant_id is not None:
+        tenant = (
+            await db_async_session.execute(select(Tenant).filter_by(id=data.tenant_id))
+        ).scalar_one_or_none()
+
+        if not tenant:
+            raise HTTPException(
+                HTTP_422_UNPROCESSABLE_ENTITY, f"can't find tenant with id {data.tenant_id}"
+            )
+        elif not tenant.active:
+            raise HTTPException(
+                HTTP_422_UNPROCESSABLE_ENTITY, f"tenant '{tenant.name}' isn't active"
+            )
+    elif not tenant.is_admin and data.tenant_id is not None and data.tenant_id != tenant.id:
+        raise HTTPException(HTTP_403_FORBIDDEN, "can't create session for other tenant")
 
     session = Session(tenant=tenant)
     db_async_session.add(session)
@@ -137,6 +160,7 @@ async def update_session(
     id: int,
     data: SessionUpdateModel,
     db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
 ):
     session = (
         await db_async_session.execute(
@@ -152,6 +176,9 @@ async def update_session(
     if not session:
         raise HTTPException(HTTP_404_NOT_FOUND)
 
+    if not tenant.is_admin and session.tenant != tenant:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
     if not session.active:
         raise HTTPException(HTTP_422_UNPROCESSABLE_ENTITY, f"session {id} is retired")
 
@@ -168,10 +195,16 @@ async def update_session(
 
 # http delete http://localhost:8080/api/v1/sessions/2
 @router.delete("/{id}", response_model=SessionResult, tags=["sessions"])
-async def delete_session(id: int, db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def delete_session(
+    id: int,
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Deletes the session with the specified **ID**
     """
+    if not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
     session = (
         await db_async_session.execute(
             select(Session)
diff --git a/duffy/app/controllers/tenant.py b/duffy/app/controllers/tenant.py
index 22acc7dc..cac33dcb 100644
--- a/duffy/app/controllers/tenant.py
+++ b/duffy/app/controllers/tenant.py
@@ -6,10 +6,16 @@
 from sqlalchemy import select
 from sqlalchemy.exc import IntegrityError
 from sqlalchemy.ext.asyncio import AsyncSession
-from starlette.status import HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
+from starlette.status import (
+    HTTP_201_CREATED,
+    HTTP_403_FORBIDDEN,
+    HTTP_404_NOT_FOUND,
+    HTTP_409_CONFLICT,
+)
 
 from ...api_models import TenantCreateModel, TenantResult, TenantResultCollection
 from ...database.model import Tenant
+from ..auth import req_tenant
 from ..database import req_db_async_session
 
 router = APIRouter(prefix="/tenants")
@@ -17,11 +23,16 @@
 
 # http get http://localhost:8080/api/v1/tenants
 @router.get("", response_model=TenantResultCollection, tags=["tenants"])
-async def get_all_tenants(db_async_session: AsyncSession = Depends(req_db_async_session)):
+async def get_all_tenants(
+    db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
+):
     """
     Return all tenants
     """
     query = select(Tenant)
+    if not tenant.is_admin:
+        query = query.filter_by(id=tenant.id)
     results = await db_async_session.execute(query)
 
     return {"action": "get", "tenants": results.scalars().all()}
@@ -32,16 +43,22 @@ async def get_all_tenants(db_async_session: AsyncSession = Depends(req_db_async_
 async def get_tenant(
     id: int,
     db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
 ):
     """
     Return the tenant with the specified **ID**
     """
-    tenant = (await db_async_session.execute(select(Tenant).filter_by(id=id))).scalar_one_or_none()
+    retrieved_tenant = (
+        await db_async_session.execute(select(Tenant).filter_by(id=id))
+    ).scalar_one_or_none()
 
-    if not tenant:
+    if not retrieved_tenant:
         raise HTTPException(HTTP_404_NOT_FOUND)
 
-    return {"action": "get", "tenant": tenant}
+    if retrieved_tenant != tenant and not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
+    return {"action": "get", "tenant": retrieved_tenant}
 
 
 # http --json post http://localhost:8080/api/v1/tenants name="Unique name"
@@ -49,20 +66,24 @@ async def get_tenant(
 async def create_tenant(
     data: TenantCreateModel,
     db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
 ):
     """
     Create a tenant with the specified **name**
     """
-    tenant = Tenant(
+    if not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
+    created_tenant = Tenant(
         name=data.name, is_admin=data.is_admin, api_key=data.api_key, ssh_key=data.ssh_key
     )
-    db_async_session.add(tenant)
+    db_async_session.add(created_tenant)
     try:
         await db_async_session.commit()
     except IntegrityError as exc:
         raise HTTPException(HTTP_409_CONFLICT, str(exc))
 
-    return {"action": "post", "tenant": tenant}
+    return {"action": "post", "tenant": created_tenant}
 
 
 # http delete http://localhost:8080/api/v1/tenant/2
@@ -70,10 +91,14 @@ async def create_tenant(
 async def delete_tenant(
     id: int,
     db_async_session: AsyncSession = Depends(req_db_async_session),
+    tenant: Tenant = Depends(req_tenant),
 ):
     """
     Delete the tenant with the specified **ID**
     """
+    if not tenant.is_admin:
+        raise HTTPException(HTTP_403_FORBIDDEN)
+
     tenant = (await db_async_session.execute(select(Tenant).filter_by(id=id))).scalar_one_or_none()
 
     if not tenant:


Test patch:
diff --git a/tests/app/conftest.py b/tests/app/conftest.py
index b809e884..2899b2e3 100644
--- a/tests/app/conftest.py
+++ b/tests/app/conftest.py
@@ -1,11 +1,111 @@
+from typing import Iterator
+
 import pytest
 from httpx import AsyncClient
+from sqlalchemy import select
 
 from duffy.app.main import app
+from duffy.database.model import Tenant
+from duffy.database.setup import _gen_test_api_key
+
+
+def pytest_configure(config):
+    config.addinivalue_line("markers", "client_auth_as")
+    config.addinivalue_line("markers", "auth_tenant")
+
+
+@pytest.fixture
+async def auth_admin(db_async_session) -> Iterator[Tenant]:
+    """A fixture creating an admin tenant with a deterministic API key.
+
+    The API key will be set deterministically, depending on the name of
+    the tenant ('admin'): ae6c10d0-0b13-554c-b976-a05d8a18f0cc
+    """
+    async with db_async_session.begin():
+        admin = (
+            await db_async_session.execute(select(Tenant).filter_by(name="admin"))
+        ).scalar_one_or_none()
+        if not admin:
+            admin = Tenant(
+                name="admin",
+                is_admin=True,
+                ssh_key="<ssh-key>",
+                api_key=_gen_test_api_key("admin"),
+            )
+            db_async_session.add(admin)
+            await db_async_session.flush()
+        else:
+            # Tenant of the same name exists (probably because the DB is filld with test data).
+            # Ensure that its API key and active attribute are valid.
+            admin.api_key = _gen_test_api_key("admin")
+
+    yield admin
+
+
+@pytest.fixture
+async def auth_tenant(request: pytest.FixtureRequest, db_async_session) -> Iterator[Tenant]:
+    """A fixture creating a tenant with a deterministic API key.
+
+    Use pytest.mark.auth_tenant(active=...) to specify whether the tenant should
+    be active or not.
+
+    The API key will be set deterministically, depending on the name of
+    the tenant ('tenant'): a8b9899d-b128-59a1-aa86-754920b7f5ed
+    """
+    active = True
+    for node in request.node.listchain():
+        for marker in node.own_markers:
+            if marker.name == "auth_tenant":
+                active = marker.kwargs.get("active", active)
+
+    async with db_async_session.begin():
+        tenant = (
+            await db_async_session.execute(select(Tenant).filter_by(name="tenant"))
+        ).scalar_one_or_none()
+        if not tenant:
+            tenant = Tenant(
+                name="tenant",
+                is_admin=False,
+                active=active,
+                ssh_key="<ssh-key>",
+                api_key=_gen_test_api_key("tenant"),
+            )
+            db_async_session.add(tenant)
+            await db_async_session.flush()
+        else:
+            # Tenant of the same name exists (probably because the DB is filld with test data).
+            # Ensure that its API key and active attribute are valid.
+            tenant.api_key = _gen_test_api_key("tenant")
+            tenant.active = active
+
+    yield tenant
 
 
 @pytest.fixture
-async def client() -> AsyncClient:
-    """A fixture creating an async client for testing the FastAPI app."""
-    async with AsyncClient(app=app, base_url="http://duffy-test.example.com") as client:
+async def client(
+    request: pytest.FixtureRequest, auth_admin: Tenant, auth_tenant: Tenant
+) -> Iterator[AsyncClient]:
+    """A fixture creating an async client for testing the FastAPI app.
+
+    This client will attempt to make authenticated API requests using
+    the `auth_tenant` fixture."""
+    auth = (auth_tenant.name, str(_gen_test_api_key(auth_tenant.name)))
+
+    for node in request.node.listchain():
+        for marker in node.own_markers:
+            if marker.name == "client_auth_as":
+                if not marker.args or marker.args[0] is None:
+                    auth = None
+                elif len(marker.args) > 1:
+                    raise ValueError("client takes no more than one argument")
+                else:
+                    name = marker.args[0]
+                    for auth_obj in (auth_admin, auth_tenant):
+                        if name == auth_obj.name:
+                            auth = (auth_obj.name, str(_gen_test_api_key(auth_obj.name)))
+                            break
+                    else:
+                        raise ValueError(f"can't create client for tenant with name '{name}'")
+
+    async with AsyncClient(app=app, base_url="http://duffy-test.example.com", auth=auth) as client:
         yield client
diff --git a/tests/app/controllers/__init__.py b/tests/app/controllers/__init__.py
index 1477f474..270a982e 100644
--- a/tests/app/controllers/__init__.py
+++ b/tests/app/controllers/__init__.py
@@ -1,13 +1,25 @@
 import re
 
 import pytest
-from starlette.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_404_NOT_FOUND, HTTP_409_CONFLICT
+from starlette.status import (
+    HTTP_200_OK,
+    HTTP_201_CREATED,
+    HTTP_403_FORBIDDEN,
+    HTTP_404_NOT_FOUND,
+    HTTP_409_CONFLICT,
+)
+
+from duffy.database.setup import _gen_test_api_key
 
 
 @pytest.mark.asyncio
 @pytest.mark.usefixtures(
-    "db_sync_schema", "db_sync_model_initialized", "db_async_schema", "db_async_model_initialized"
+    "db_sync_schema",
+    "db_sync_model_initialized",
+    "db_async_schema",
+    "db_async_model_initialized",
 )
+@pytest.mark.client_auth_as("admin")
 class BaseTestController:
     """A class testing controllers.
 
@@ -26,6 +38,9 @@ class BaseTestController:
                 it is in the error detail (case-insensitively). If it is
                 a regular expression pattern object, check if it can
                 matched anywhere in the error detail string.
+        create_unprivileged:
+                Whether or not non-admin users are allowed to create
+                objects.
 
     Example:
         class TestFooController(BaseTestController):
@@ -68,6 +83,7 @@ async def test_method(client):
     attrs = {}
     no_response_attrs = ()
     unique = False
+    create_unprivileged = False
 
     @property
     def name_plural(self):
@@ -75,12 +91,27 @@ def name_plural(self):
         return self.name + "s"
 
     @classmethod
-    async def _create_obj(cls, client, attrs: dict = None, merge_cls_attrs: bool = True):
+    async def _create_obj(
+        cls,
+        client,
+        attrs: dict = None,
+        merge_cls_attrs: bool = True,
+        client_kwargs: dict = None,
+        subcls_client_kwargs: dict = None,
+    ):
         if merge_cls_attrs:
             attrs = {**(cls.attrs or {}), **(attrs or {})}
         else:
             attrs = attrs or {}
 
+        if client_kwargs is None:
+            client_kwargs = {}
+
+        if subcls_client_kwargs is None:
+            subcls_client_kwargs = client_kwargs
+        else:
+            subcls_client_kwargs = {**client_kwargs, **subcls_client_kwargs}
+
         for name, value in attrs.items():
             try:
                 subcls, item = value
@@ -88,14 +119,16 @@ async def _create_obj(cls, client, attrs: dict = None, merge_cls_attrs: bool = T
                 pass
             else:
                 if isinstance(subcls, type) and issubclass(subcls, BaseTestController):
-                    subresponse = await subcls._create_obj(client)
+                    subresponse = await subcls._create_obj(
+                        client, client_kwargs=subcls_client_kwargs
+                    )
                     subresult = subresponse.json()
                     value = subresult[subcls.name]
                     for elem in item.split("."):
                         value = value[elem]
                 attrs[name] = value
 
-        response = await client.post(cls.path, json=attrs)
+        response = await client.post(cls.path, json=attrs, **client_kwargs)
         return response
 
     @classmethod
@@ -104,9 +137,6 @@ def _verify_item(cls, item, attrs=None, no_response_attrs=None):
             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
@@ -120,12 +150,28 @@ def _verify_item(cls, item, attrs=None, no_response_attrs=None):
                     continue
             assert item[key] == value
 
-    async def test_create_obj(self, client):
+    @pytest.mark.parametrize(
+        "unprivileged",
+        (
+            False,
+            pytest.param(True, marks=pytest.mark.client_auth_as("tenant")),
+        ),
+    )
+    async def test_create_obj(self, unprivileged, client, auth_admin):
         """Test that objects can be created on the API endpoint."""
-        response = await self._create_obj(client)
-        assert response.status_code == HTTP_201_CREATED
-        result = response.json()
-        self._verify_item(result[self.name])
+        # Ensure that dependencies are created as admin tenant.
+        response = await self._create_obj(
+            client,
+            subcls_client_kwargs={
+                "auth": (auth_admin.name, str(_gen_test_api_key(auth_admin.name)))
+            },
+        )
+        if not unprivileged or self.create_unprivileged:
+            assert response.status_code == HTTP_201_CREATED
+            result = response.json()
+            self._verify_item(result[self.name])
+        else:
+            assert response.status_code == HTTP_403_FORBIDDEN
 
     @classmethod
     def _add_attrs_from_response(cls, response):
@@ -175,8 +221,11 @@ async def test_retrieve_obj(self, client, testcase):
         """Test that an object can be retrieved via the API endpoint."""
         if testcase != "not found":
             create_response = await self._create_obj(client)
+            obj_id = create_response.json()[self.name]["id"]
+        else:
+            obj_id = -1
 
-        response = await client.get(f"{self.path}/1")
+        response = await client.get(f"{self.path}/{obj_id}")
 
         if testcase == "found":
             assert response.status_code == HTTP_200_OK
@@ -193,19 +242,35 @@ async def test_retrieve_collection(self, client):
         response = await client.get(self.path)
         result = response.json()
         assert self.name_plural in result
-        obj = result[self.name_plural][0]
-        assert obj["id"] == 1
+        objs = result[self.name_plural]
+        obj = objs[-1]
+        assert obj["id"] == len(objs)
         attrs = {**self.attrs, **self._add_attrs_from_response(create_response)}
         self._verify_item(obj, attrs=attrs)
 
-    @pytest.mark.parametrize("testcase", ("found", "not found"))
-    async def test_delete_obj(self, client, testcase):
+    @pytest.mark.parametrize(
+        "testcase",
+        (
+            "found",
+            "not found",
+            pytest.param("not admin", marks=pytest.mark.client_auth_as("tenant")),
+        ),
+    )
+    async def test_delete_obj(self, client, testcase, auth_admin):
         if testcase != "not found":
-            await self._create_obj(client)
+            create_response = await self._create_obj(
+                client,
+                client_kwargs={"auth": (auth_admin.name, str(_gen_test_api_key(auth_admin.name)))},
+            )
+            obj_id = create_response.json()[self.name]["id"]
+        else:
+            obj_id = -1
 
-        response = await client.delete(f"{self.path}/1")
+        response = await client.delete(f"{self.path}/{obj_id}")
 
         if testcase == "found":
             assert response.status_code == HTTP_200_OK
+        elif testcase == "not admin":
+            assert response.status_code == HTTP_403_FORBIDDEN
         else:
             assert response.status_code == HTTP_404_NOT_FOUND
diff --git a/tests/app/controllers/test_session.py b/tests/app/controllers/test_session.py
index 95d59b3e..bd450d30 100644
--- a/tests/app/controllers/test_session.py
+++ b/tests/app/controllers/test_session.py
@@ -6,6 +6,7 @@
 from starlette.status import (
     HTTP_200_OK,
     HTTP_201_CREATED,
+    HTTP_403_FORBIDDEN,
     HTTP_404_NOT_FOUND,
     HTTP_422_UNPROCESSABLE_ENTITY,
 )
@@ -13,7 +14,6 @@
 from duffy.database.model import Node, PhysicalNode, Session, Tenant, VirtualNode
 
 from . import BaseTestController
-from .test_tenant import TestTenant as _TestTenant
 
 
 class TestSession(BaseTestController):
@@ -21,31 +21,76 @@ class TestSession(BaseTestController):
     name = "session"
     path = "/api/v1/sessions"
     attrs = {
-        "tenant_id": (_TestTenant, "id"),
         # don't bother with actually allocating nodes here
         "nodes_specs": [],
     }
     no_response_attrs = ("nodes_specs",)
+    create_unprivileged = True
 
-    async def test_create_unknown_tenant(self, client):
-        # setting tenant_id manually will skip the code which would create the tenant
-        response = await self._create_obj(client, attrs={"tenant_id": 1})
+    @staticmethod
+    async def _create_tenant(db_async_session, **kwargs):
+        for key, value in {
+            "name": "Other User",
+            "api_key": uuid.uuid4(),
+            "ssh_key": "<ssh key>",
+        }.items():
+            kwargs.setdefault(key, value)
+        tenant = Tenant(**kwargs)
+        db_async_session.add(tenant)
+        await db_async_session.flush()
+        return tenant
+
+    async def test_create_other_tenant(self, client, db_async_session, auth_tenant):
+        other_tenant = await self._create_tenant(db_async_session)
+        response = await self._create_obj(client, attrs={"tenant_id": other_tenant.id})
+        assert response.status_code == HTTP_201_CREATED
+        result = response.json()
+        assert result["session"]["tenant"]["id"] == other_tenant.id
+
+    async def test_create_unknown_tenant(self, client, auth_tenant):
+        response = await self._create_obj(client, attrs={"tenant_id": auth_tenant.id + 1})
         assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
         result = response.json()
         assert re.match(r"^can't find tenant with id \d+$", result["detail"])
 
     async def test_create_retired_tenant(self, client, db_async_session):
-        # Create the tenant manually and set it as retired
-        tenant = Tenant(name="Happily retired", active=False, api_key=uuid.uuid4(), ssh_key="BOO")
-        db_async_session.add(tenant)
-        await db_async_session.flush()
+        retired_tenant = await self._create_tenant(
+            db_async_session, name="Happily retired", active=False
+        )
 
-        response = await self._create_obj(client, attrs={"tenant_id": tenant.id})
+        response = await self._create_obj(client, attrs={"tenant_id": retired_tenant.id})
 
         assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
         result = response.json()
         assert re.match(r"^tenant .* isn't active$", result["detail"])
 
+    @pytest.mark.client_auth_as("tenant")
+    async def test_retrieve_obj_other_tenant(self, client, db_async_session):
+        other_tenant = await self._create_tenant(db_async_session)
+        session = Session(tenant=other_tenant)
+        db_async_session.add(session)
+        await db_async_session.flush()
+
+        response = await client.get(f"{self.path}/{session.id}")
+        assert response.status_code == HTTP_403_FORBIDDEN
+
+    @pytest.mark.client_auth_as("tenant")
+    async def test_retrieve_collection_filtered(self, client, db_async_session, auth_tenant):
+        other_tenant = await self._create_tenant(db_async_session)
+
+        # Create a couple of sessions, some of them owned by the authenticated tenant, some others
+        # by someone else.
+        for i in range(10):
+            tenant = auth_tenant if i % 2 else other_tenant
+            # Use tenant_id here because the auth_tenant object belongs to another session, the one
+            # used in its fixture.
+            db_async_session.add(Session(tenant_id=tenant.id))
+        await db_async_session.flush()
+
+        response = await client.get(self.path)
+        result = response.json()
+        assert all(session["tenant"]["id"] == auth_tenant.id for session in result["sessions"])
+
 
 @pytest.mark.usefixtures("db_async_test_data", "db_async_model_initialized")
 @pytest.mark.asyncio
@@ -69,26 +114,28 @@ class TestSessionWorkflow:
         },
     ]
 
-    @staticmethod
-    async def _get_tenant_obj(db_async_session):
-        return (
-            await db_async_session.execute(select(Tenant).filter_by(name="tenant"))
-        ).scalar_one()
-
-    @pytest.mark.parametrize("can_reserve", (True, False))
-    async def test_request_session(self, can_reserve, client, db_async_session):
-        tenant = await self._get_tenant_obj(db_async_session)
-
-        if not can_reserve:
+    @pytest.mark.parametrize(
+        "testcase",
+        (
+            "normal",
+            pytest.param("inactive tenant", marks=pytest.mark.auth_tenant(active=False)),
+            "insufficient nodes",
+            "wrong tenant",
+        ),
+    )
+    async def test_request_session(self, testcase, client, db_async_session, auth_tenant):
+        if testcase == "insufficient nodes":
             for node in (await db_async_session.execute(select(Node))).scalars():
                 node.state = "deployed"
-            await db_async_session.flush()
+            await db_async_session.commit()
 
-        request_payload = {"tenant_id": tenant.id, "nodes_specs": self.nodes_specs}
+        request_payload = {"nodes_specs": self.nodes_specs}
+        if testcase == "wrong tenant":
+            request_payload["tenant_id"] = auth_tenant.id + 1
         response = await client.post(self.path, json=request_payload)
         result = response.json()
 
-        if can_reserve:
+        if testcase == "normal":
             assert response.status_code == HTTP_201_CREATED
 
             # validate nodes have been allocated in the database
@@ -130,15 +177,21 @@ async def test_request_session(self, can_reserve, client, db_async_session):
                         # didn't break out of loop -> matches spec
                         matched_nodes_count += 1
                 assert matched_nodes_count == quantity
-        else:  # not can_reserve
+        elif testcase == "inactive tenant":
+            assert response.status_code == HTTP_403_FORBIDDEN
+        elif testcase == "insufficient nodes":
             assert response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
             assert result["detail"].startswith("can't reserve nodes:")
-
-    @pytest.mark.parametrize("testcase", ("normal", "unknown-session", "retired-session"))
-    async def test_update_session(self, testcase, client, db_async_session):
+        else:  # testcase == "wrong tenant"
+            assert response.status_code == HTTP_403_FORBIDDEN
+            assert result["detail"] == "can't create session for other tenant"
+
+    @pytest.mark.parametrize(
+        "testcase", ("normal", "unknown-session", "retired-session", "unauthorized")
+    )
+    async def test_update_session(self, testcase, client, db_async_session, auth_tenant):
         if testcase != "unknown-session":
-            tenant = await self._get_tenant_obj(db_async_session)
-            request_payload = {"tenant_id": tenant.id, "nodes_specs": self.nodes_specs}
+            request_payload = {"nodes_specs": self.nodes_specs}
             create_response = await client.post(self.path, json=request_payload)
             create_result = create_response.json()
             created_session = create_result["session"]
@@ -148,11 +201,21 @@ async def test_update_session(self, testcase, client, db_async_session):
             assert created_session["retired_at"] is None
 
             if testcase == "retired-session":
-                retired_session = (
-                    await db_async_session.execute(select(Session).filter_by(id=session_id))
-                ).scalar_one()
-                retired_session.active = False
-                await db_async_session.flush()
+                async with db_async_session.begin():
+                    retired_session = (
+                        await db_async_session.execute(select(Session).filter_by(id=session_id))
+                    ).scalar_one()
+                    retired_session.active = False
+            elif testcase == "unauthorized":
+                # make the session be owned by the admin tenant
+                async with db_async_session.begin():
+                    admin_tenant = (
+                        await db_async_session.execute(select(Tenant).filter_by(name="admin"))
+                    ).scalar_one()
+                    session = (
+                        await db_async_session.execute(select(Session).filter_by(id=session_id))
+                    ).scalar_one()
+                    session.tenant = admin_tenant
         else:
             session_id = 1
 
@@ -166,6 +229,8 @@ async def test_update_session(self, testcase, client, db_async_session):
             assert updated_session["retired_at"] is not None
         elif testcase == "unknown-session":
             assert update_response.status_code == HTTP_404_NOT_FOUND
+        elif testcase == "unauthorized":
+            assert update_response.status_code == HTTP_403_FORBIDDEN
         else:  # testcase == "retired-session"
             assert update_response.status_code == HTTP_422_UNPROCESSABLE_ENTITY
             assert re.match(r"^session .* is retired$", update_result["detail"])
diff --git a/tests/app/controllers/test_tenant.py b/tests/app/controllers/test_tenant.py
index b28c4b2d..c62cb1c9 100644
--- a/tests/app/controllers/test_tenant.py
+++ b/tests/app/controllers/test_tenant.py
@@ -1,6 +1,9 @@
-from uuid import uuid4
+import uuid
 
-from starlette.status import HTTP_201_CREATED
+import pytest
+from starlette.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
+
+from duffy.database.setup import _gen_test_api_key
 
 from . import BaseTestController
 
@@ -11,7 +14,7 @@ class TestTenant(BaseTestController):
     path = "/api/v1/tenants"
     attrs = {
         "name": "Some Honky Tenant!",
-        "api_key": str(uuid4()),
+        "api_key": str(uuid.uuid4()),
         "ssh_key": "With a honky SSH key!",
     }
     no_response_attrs = ("api_key",)
@@ -22,3 +25,25 @@ async def test_with_is_admin_set(self, client):
         assert response.status_code == HTTP_201_CREATED
         result = response.json()
         self._verify_item(result[self.name])
+
+    @pytest.mark.client_auth_as("tenant")
+    async def test_retrieve_obj_other_tenant(self, client, auth_admin):
+        other_tenant_response = await self._create_obj(
+            client,
+            attrs={"name": "Another Tenant"},
+            client_kwargs={"auth": (auth_admin.name, str(_gen_test_api_key(auth_admin.name)))},
+        )
+        other_tenant_id = other_tenant_response.json()["tenant"]["id"]
+        response = await client.get(f"{self.path}/{other_tenant_id}")
+        assert response.status_code == HTTP_403_FORBIDDEN
+
+    @pytest.mark.client_auth_as("tenant")
+    async def test_retrieve_collection_filtered(self, client, auth_admin, auth_tenant):
+        await self._create_obj(
+            client,
+            attrs={"name": "Another Tenant"},
+            client_kwargs={"auth": (auth_admin.name, str(_gen_test_api_key(auth_admin.name)))},
+        )
+        response = await client.get(self.path)
+        result = response.json()
+        assert all(tenant["id"] == auth_tenant.id for tenant in result["tenants"])
diff --git a/tests/app/test_auth.py b/tests/app/test_auth.py
new file mode 100644
index 00000000..9cbf38f1
--- /dev/null
+++ b/tests/app/test_auth.py
@@ -0,0 +1,75 @@
+from unittest import mock
+
+import pytest
+from fastapi.exceptions import HTTPException
+from sqlalchemy import select
+from starlette.status import HTTP_401_UNAUTHORIZED, HTTP_403_FORBIDDEN
+
+from duffy.app.auth import _req_tenant_factory
+from duffy.database.model import Tenant
+from duffy.database.setup import _gen_test_api_key
+
+from ..util import noop_context
+
+
+@pytest.mark.parametrize(
+    "testcase",
+    (
+        "authenticated",
+        "unauthenticated",
+        "unauthenticated-optional",
+        "authenticated-unknown",
+        "authenticated-retired",
+    ),
+)
+@pytest.mark.asyncio
+async def test__req_tenant_factory(testcase, db_async_session, db_async_test_data):
+    if "unauthenticated" in testcase:
+        credentials = None
+    else:
+        credentials = mock.MagicMock()
+        if "unknown" not in testcase:
+            credentials.username = "tenant"
+            api_key = _gen_test_api_key("tenant")
+            credentials.password = str(api_key)
+        else:
+            credentials.username = "BOOP"
+            credentials.password = "FOO"
+
+    if "retired" in testcase:
+        for db_tenant in (
+            await db_async_session.execute(select(Tenant).filter_by(name=credentials.username))
+        ).scalars():
+            db_tenant.active = False
+        await db_async_session.flush()
+
+    if "unauthenticated" in testcase and "optional" not in testcase:
+        expectation = pytest.raises(HTTPException)
+        exception_args = (HTTP_403_FORBIDDEN,)
+    elif "unauthenticated" not in testcase and "unknown" in testcase:
+        expectation = pytest.raises(HTTPException)
+        exception_args = (HTTP_401_UNAUTHORIZED,)
+    elif "unauthenticated" not in testcase and "retired" in testcase:
+        expectation = pytest.raises(HTTPException)
+        exception_args = (HTTP_403_FORBIDDEN,)
+    else:
+        expectation = noop_context()
+        exception_args = None
+
+    get_req_tenant = _req_tenant_factory(optional="optional" in testcase)
+
+    with expectation as excinfo:
+        tenant = await get_req_tenant(db_async_session=db_async_session, credentials=credentials)
+
+    if exception_args:
+        assert excinfo.value.args == exception_args
+    else:
+        if "unauthenticated" not in testcase:
+            assert tenant
+            assert tenant.active
+            assert tenant.name == credentials.username
+            assert tenant.validate_api_key(api_key)
+        else:
+            # ensure not testcase is overlooked
+            assert "optional" in testcase
+            assert tenant is None
diff --git a/tests/app/test_main.py b/tests/app/test_main.py
index 0bae1097..95955863 100644
--- a/tests/app/test_main.py
+++ b/tests/app/test_main.py
@@ -9,6 +9,7 @@
 from ..util import noop_context
 
 
+@pytest.mark.client_auth_as(None)
 @pytest.mark.asyncio
 class TestMain:
     api_paths = (