Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM
Building secure user registration, password hashing, organization onboarding, owner membership creation, transaction boundaries, validation, and integration tests.

1. Introduction
In Part 6, Quorentra gained its first real domain model.
We created:
Organization │ ▼Membership ▲ │ User
The database can now represent:
- organizations;
- users;
- organization memberships;
- membership roles;
- tenant ownership relationships.
But there is still no way for an actual user to enter Quorentra.
Creating records manually through Python or pgAdmin is not a product workflow.
That changes in Part 7.
We are going to implement:
POST /api/v1/auth/register
A single request will create:
User +Organization +Owner Membership
inside one database transaction.
This will become Quorentra’s first complete business operation.
By the end of this article, Quorentra will reach:
Quorentra 0.0.5 — Registration + Organization Onboarding
2. The First Real User Journey
Until now, most of our implementation has been infrastructure.
We built:
Repository ↓FastAPI ↓PostgreSQL ↓SQLAlchemy ↓Alembic ↓Tenant Domain
Now we begin building behavior.
The first user journey is:
New User │ ▼Registration Request │ ▼Quorentra │ ├── Create User ├── Create Organization └── Create Owner Membership
If everything succeeds:
COMMIT
If anything fails:
ROLLBACK
This transaction establishes the user’s initial Quorentra workspace.
3. Why Registration Is an Application Use Case
We could expose three endpoints:
POST /usersPOST /organizationsPOST /memberships
and force the client to orchestrate them.
That would be poor application design.
The business operation is not:
Create three database rows.
The business operation is:
Register a new Quorentra organization owner.
Therefore the backend should own the workflow.
The frontend should only need:
POST /api/v1/auth/register
This distinction becomes increasingly important as Quorentra grows.
4. The Registration Transaction
Our transaction will eventually perform:
BEGIN │ ▼Normalize Email │ ▼Check Existing User │ ▼Generate Organization Slug │ ▼Hash Password │ ▼Create User │ ▼Create Organization │ ▼Create Owner Membership │ ▼COMMIT
If any database operation fails:
ROLLBACK
We must never leave the system in a state such as:
User created ✓Organization created ✗Membership created ✗
Atomicity is therefore one of the central requirements of Part 7.
5. Starting Checkpoint
Part 7 assumes Part 6 is complete.
The relevant module structure should resemble:
app/├── db/│ ├── base.py│ ├── dependencies.py│ ├── mixins.py│ ├── models.py│ └── session.py│└── modules/ ├── memberships/ │ ├── constants.py │ ├── model.py │ └── repository.py │ ├── organizations/ │ ├── model.py │ ├── repository.py │ └── service.py │ └── users/ ├── model.py └── repository.py
The database should contain:
organizationsusersmembershipsalembic_version
Verify:
python -m pytest
and:
python -m alembic current
before continuing.
6. Registration Requires Password Storage
Our current User model contains:
idemaildisplay_nameis_activecreated_atupdated_at
There is deliberately no password yet.
Registration changes that requirement.
We now need to persist authentication credentials.
But Quorentra must never store:
password
in plaintext.
Instead, it stores:
password_hash
The relationship is:
User enters password │ ▼Password Hashing │ ▼Irreversible Hash │ ▼Database
The original password is never stored.
7. Install Password Hashing Support
For the MVP, we will use pwdlib with Argon2 support.
From:
quorentra/backend
run:
python -m pip install "pwdlib[argon2]"
Then verify:
python -m pip show pwdlib
Update:
requirements.txt
to include:
pwdlib[argon2]
The runtime dependencies now include:
fastapiuvicorn[standard]sqlalchemypsycopg[binary]pydantic-settingsalembicpwdlib[argon2]
8. Why Argon2?
Passwords should not be protected with general-purpose hashes such as:
SHA-256SHA-512MD5
Password hashing requires deliberately expensive algorithms designed to resist brute-force attacks.
Argon2 is designed specifically for password hashing.
Quorentra will therefore use:
Plain Password ↓Argon2 ↓Password Hash
Later login will perform:
Submitted Password +Stored Hash ↓Verification
without ever decrypting a password.
9. Create the Security Package
Create:
app/core/security.py
From PowerShell:
New-Item app\core\security.py -ItemType File
Add:
from pwdlib import PasswordHashpassword_hash = PasswordHash.recommended()def hash_password(password: str) -> str: return password_hash.hash(password)def verify_password( plain_password: str, hashed_password: str,) -> bool: return password_hash.verify( plain_password, hashed_password, )
The second function will become useful in Part 8 when login is implemented.
10. Test Password Hashing Manually
From:
quorentra/backend
run:
python -c "from app.core.security import hash_password; print(hash_password('TestPassword123!'))"
You should receive a long Argon2 hash.
Run the command again.
The result should be different.
That is expected because password hashing uses a salt.
Now test verification:
python -c "from app.core.security import hash_password, verify_password; h=hash_password('TestPassword123!'); print(verify_password('TestPassword123!', h))"
Expected:
True
Try an incorrect password:
python -c "from app.core.security import hash_password, verify_password; h=hash_password('TestPassword123!'); print(verify_password('WrongPassword', h))"
Expected:
False
The password foundation works.
11. Add Password Hash to User
Open:
app/modules/users/model.py
Add:
password_hash: Mapped[str] = mapped_column( String(500), nullable=False,)
The user model now conceptually contains:
User├── id├── email├── password_hash├── display_name├── is_active├── created_at└── updated_at
Notice again:
password_hash
not:
password
12. Generate the Password Migration
Run:
python -m alembic revision --autogenerate -m "add user password hash"
Inspect the generated migration.
You should see something similar to:
op.add_column( "users", sa.Column( "password_hash", sa.String(length=500), nullable=False, ),)
There is an important complication.
If your users table already contains test users from Part 6, PostgreSQL cannot simply add a non-null column without values for those existing records.
13. Handling Existing Development Data
Because Quorentra is still at an early local-development stage, you have two practical options.
If the existing user records are only test data, the cleanest approach is to delete them before applying the migration.
Because memberships reference users, remove test memberships first:
DELETE FROM memberships;DELETE FROM users;
Organizations can remain if desired, although for a clean development database you may also remove test organizations:
DELETE FROM organizations;
Then apply:
python -m alembic upgrade head
At later production stages, we would never casually delete real data to simplify a migration.
We would design a staged migration.
At this point, however, the database contains only development fixtures.
14. Update Part 6 Integration Tests
The Part 6 tests create users directly.
They must now provide a password hash.
For example:
from app.core.security import hash_password
Then:
user = User( email=f"test-{unique_value}@example.com", display_name="Test User", password_hash=hash_password("TestPassword123!"),)
Update all direct User(...) construction in the existing tests.
Then run:
python -m pytest
The existing tests should pass again before registration development continues.
15. Create the Authentication Module
Registration and login belong to authentication/application identity rather than directly inside the user persistence module.
Create:
app/modules/auth/
Run:
mkdir app\modules\authNew-Item app\modules\auth\__init__.py -ItemType File
The module will eventually contain:
auth/├── __init__.py├── router.py├── schemas.py└── service.py
Create the remaining files:
New-Item app\modules\auth\router.py -ItemType FileNew-Item app\modules\auth\schemas.py -ItemType FileNew-Item app\modules\auth\service.py -ItemType File
16. Why Authentication Gets Its Own Module
It might seem logical to put registration under:
users/
But registration coordinates:
UserOrganizationMembershipPassword Security
It crosses several domain modules.
That makes it an application-level authentication/onboarding capability.
Therefore:
auth/
owns the use case while:
users/organizations/memberships/
continue owning their respective domain persistence.
This is an important modular boundary.
17. Create the Registration Request Schema
Open:
app/modules/auth/schemas.py
Add:
from uuid import UUIDfrom pydantic import BaseModel, EmailStr, Field
We are going to use Pydantic’s EmailStr.
That requires email validation support.
Install:
python -m pip install email-validator
Add:
email-validator
to requirements.txt.
Now define:
class RegisterRequest(BaseModel): email: EmailStr password: str = Field( min_length=12, max_length=128, ) display_name: str | None = Field( default=None, max_length=200, ) organization_name: str = Field( min_length=2, max_length=200, )
This becomes the public registration input contract.
18. Why Validate at the API Boundary?
A request such as:
{ "email": "not-an-email", "password": "123", "organization_name": ""}
should not reach our database layer.
FastAPI and Pydantic can reject malformed input immediately.
This gives us:
HTTP Request │ ▼Pydantic Validation │ ▼Application Service
instead of:
Invalid Data │ ▼Database │ ▼Failure
Validation belongs as close as practical to the system boundary.
19. Password Length Is Not the Entire Password Policy
For the MVP we require:
minimum 12 charactersmaximum 128 characters
We deliberately avoid complicated composition rules such as:
must contain:1 uppercase1 lowercase1 digit1 symbol
Long passwords and passphrases are generally easier for users and provide strong entropy.
Later we can add:
- compromised-password checking;
- rate limiting;
- password reset;
- optional MFA;
- enterprise identity providers.
Those are not required for the first registration transaction.
20. Create the Registration Response Schema
Add to:
app/modules/auth/schemas.py
the following:
class RegisterResponse(BaseModel): user_id: UUID organization_id: UUID membership_id: UUID email: EmailStr organization_name: str role: str
Notice what the response does not include:
passwordpassword_hash
Password hashes must never leave the backend.
21. Example Registration Contract
The client will send:
{ "email": "owner@example.com", "password": "VerySecurePassword123!", "display_name": "CRM Owner", "organization_name": "Acme Consulting"}
Quorentra will return something conceptually similar to:
{ "user_id": "6e866c7c-99bd-48a5-b4db-c957b834ef8f", "organization_id": "74d86618-f169-4978-a268-f92a37c93cc4", "membership_id": "b84b175a-4742-4ca1-b713-b36e54ce5ec2", "email": "owner@example.com", "organization_name": "Acme Consulting", "role": "owner"}
This confirms that onboarding succeeded.
22. Normalize Email Addresses
Create:
app/modules/users/utils.py
Run:
New-Item app\modules\users\utils.py -ItemType File
Add:
def normalize_email(email: str) -> str: return email.strip().lower()
For the MVP:
Owner@Example.com
becomes:
owner@example.com
before persistence.
This helps ensure uniqueness behaves consistently.
23. Organization Slug Generation
Organizations need unique slugs.
Create:
app/modules/organizations/utils.py
Run:
New-Item app\modules\organizations\utils.py -ItemType File
Add:
import redef slugify(value: str) -> str: value = value.strip().lower() value = re.sub( r"[^a-z0-9]+", "-", value, ) return value.strip("-")
Examples:
Acme Consulting
becomes:
acme-consulting
and:
North Star CRM Solutions
becomes:
north-star-crm-solutions
24. Slugs Must Be Unique
Suppose two organizations register as:
Acme Consulting
Both would naturally produce:
acme-consulting
But the database requires unique slugs.
We therefore need collision handling.
One option would be:
acme-consultingacme-consulting-2acme-consulting-3
But that requires querying and incrementing.
For the MVP, we can append a short random suffix when a collision exists.
For example:
acme-consultingacme-consulting-a7f32c
This keeps the logic simple and robust.
25. Enhance the Organization Repository
Open:
app/modules/organizations/repository.py
We already have:
get_by_slug()
That is exactly what the onboarding service needs.
We can also add:
def slug_exists( self, slug: str,) -> bool: return self.get_by_slug(slug) is not None
The repository now owns the persistence query.
The service owns the collision strategy.
26. Enhance the User Repository
Open:
app/modules/users/repository.py
We already have:
get_by_email()
Add:
def email_exists( self, email: str,) -> bool: return self.get_by_email(email) is not None
This will allow registration to detect existing accounts before attempting an insert.
Remember:
The database unique constraint remains the final safeguard.
Application checks exist to provide better behavior and clearer errors.
27. Define an Authentication Error
Create:
app/modules/auth/exceptions.py
Run:
New-Item app\modules\auth\exceptions.py -ItemType File
Add:
class RegistrationError(Exception): passclass EmailAlreadyRegisteredError( RegistrationError): pass
This keeps application errors independent from HTTP.
The service should not need to know whether an error eventually becomes:
HTTP 409
or is consumed by another interface such as MCP.
That translation belongs at the boundary.
28. Why Application Exceptions Matter for ChatGPT-Native Design
This separation may seem small now, but it becomes important later.
The same application service could eventually be invoked by:
REST APIMCP ToolBackground WorkerAdministrative CLI
The service should raise:
EmailAlreadyRegisteredError
rather than:
HTTPException(status_code=409)
because HTTP is only one interface.
This helps keep the core application reusable when ChatGPT and MCP are added.
29. Build the Registration Service
Open:
app/modules/auth/service.py
Add:
from uuid import uuid4from sqlalchemy.orm import Sessionfrom app.core.security import hash_passwordfrom app.modules.auth.exceptions import ( EmailAlreadyRegisteredError,)from app.modules.auth.schemas import ( RegisterRequest, RegisterResponse,)from app.modules.memberships.constants import ROLE_OWNERfrom app.modules.memberships.model import Membershipfrom app.modules.memberships.repository import ( MembershipRepository,)from app.modules.organizations.model import Organizationfrom app.modules.organizations.repository import ( OrganizationRepository,)from app.modules.organizations.utils import slugifyfrom app.modules.users.model import Userfrom app.modules.users.repository import UserRepositoryfrom app.modules.users.utils import normalize_email
Now create:
class AuthService: def __init__(self, db: Session) -> None: self.db = db self.users = UserRepository(db) self.organizations = OrganizationRepository(db) self.memberships = MembershipRepository(db)
This service coordinates the three domain repositories.
30. Generate a Unique Organization Slug
Inside AuthService, add:
def _generate_unique_slug( self, organization_name: str,) -> str: base_slug = slugify(organization_name) if not self.organizations.slug_exists(base_slug): return base_slug suffix = uuid4().hex[:6] return f"{base_slug}-{suffix}"
This gives us:
Acme Consulting ↓acme-consulting
unless that slug already exists.
Then:
acme-consulting-a12f4e
may be generated.
31. Guard Against Empty Slugs
There is one edge case.
An organization name could technically contain characters that our simple slug function removes entirely.
For example:
!!!
Pydantic’s length validation would allow three characters, but:
slugify("!!!")
would produce:
""
We should guard against this.
Update:
def _generate_unique_slug( self, organization_name: str,) -> str: base_slug = slugify(organization_name) if not base_slug: base_slug = "organization" if not self.organizations.slug_exists(base_slug): return base_slug suffix = uuid4().hex[:6] return f"{base_slug}-{suffix}"
This is sufficient for the MVP.
32. Implement Registration
Inside AuthService, add:
def register( self, request: RegisterRequest,) -> RegisterResponse: email = normalize_email(str(request.email)) if self.users.email_exists(email): raise EmailAlreadyRegisteredError( "A user with this email already exists." ) organization_slug = self._generate_unique_slug( request.organization_name ) user = User( email=email, display_name=request.display_name, password_hash=hash_password(request.password), ) organization = Organization( name=request.organization_name.strip(), slug=organization_slug, ) membership = Membership( user=user, organization=organization, role=ROLE_OWNER, ) self.users.add(user) self.organizations.add(organization) self.memberships.add(membership) self.db.commit() self.db.refresh(user) self.db.refresh(organization) self.db.refresh(membership) return RegisterResponse( user_id=user.id, organization_id=organization.id, membership_id=membership.id, email=user.email, organization_name=organization.name, role=membership.role, )
We now have the first complete onboarding use case.
33. Why Add All Three Objects Before Commit?
SQLAlchemy understands the relationships between:
UserOrganizationMembership
The unit of work can therefore persist the related object graph within the same transaction.
Conceptually:
Session │ ├── User ├── Organization └── Membership │ ▼ COMMIT
This is much safer than committing after every insert.
34. Do Not Commit Inside Repositories
Notice that:
UserRepository.add()OrganizationRepository.add()MembershipRepository.add()
do not call:
self.db.commit()
That is intentional.
If each repository committed independently, our transaction would become:
Create UserCOMMITCreate OrganizationCOMMITCreate MembershipCOMMIT
If membership creation then failed, the previous two records would remain.
Instead, the application service owns the transaction boundary:
Service │ ├── Repository ├── Repository ├── Repository │ ▼COMMIT
This is one of the most important architectural patterns introduced in this article.
35. Add Explicit Rollback Handling
Although SQLAlchemy will allow us to manage transaction failure externally, it is useful for this first service to make the behavior explicit.
Update the persistence section:
try: self.users.add(user) self.organizations.add(organization) self.memberships.add(membership) self.db.commit()except Exception: self.db.rollback() raise
Then perform the refresh operations after the successful commit.
This makes the transaction behavior easy to understand.
36. The Complete Registration Service
The important method now conceptually looks like:
def register( self, request: RegisterRequest,) -> RegisterResponse: email = normalize_email(str(request.email)) if self.users.email_exists(email): raise EmailAlreadyRegisteredError( "A user with this email already exists." ) organization_slug = self._generate_unique_slug( request.organization_name ) user = User( email=email, display_name=request.display_name, password_hash=hash_password(request.password), ) organization = Organization( name=request.organization_name.strip(), slug=organization_slug, ) membership = Membership( user=user, organization=organization, role=ROLE_OWNER, ) try: self.users.add(user) self.organizations.add(organization) self.memberships.add(membership) self.db.commit() except Exception: self.db.rollback() raise self.db.refresh(user) self.db.refresh(organization) self.db.refresh(membership) return RegisterResponse( user_id=user.id, organization_id=organization.id, membership_id=membership.id, email=user.email, organization_name=organization.name, role=membership.role, )
This is Quorentra’s first substantial application-service operation.
37. Build the Authentication Router
Open:
app/modules/auth/router.py
Add:
from fastapi import APIRouter, Depends, HTTPException, statusfrom sqlalchemy.orm import Sessionfrom app.db.dependencies import get_dbfrom app.modules.auth.exceptions import ( EmailAlreadyRegisteredError,)from app.modules.auth.schemas import ( RegisterRequest, RegisterResponse,)from app.modules.auth.service import AuthServicerouter = APIRouter( prefix="/auth", tags=["auth"],)
Now create the registration endpoint.
38. Create POST /auth/register
Add:
router.post( "/register", response_model=RegisterResponse, status_code=status.HTTP_201_CREATED,)def register( request: RegisterRequest, db: Session = Depends(get_db),) -> RegisterResponse: service = AuthService(db) try: return service.register(request) except EmailAlreadyRegisteredError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=str(exc), ) from exc
The router translates application errors into HTTP semantics.
The service itself remains independent of FastAPI.
39. Register the Authentication Router
Open:
app/api/v1/router.py
It currently includes the health router.
Add:
from app.modules.auth.router import router as auth_router
Then:
api_router.include_router(auth_router)
The router file now conceptually contains:
from fastapi import APIRouterfrom app.api.v1.health import router as health_routerfrom app.modules.auth.router import router as auth_routerapi_router = APIRouter()api_router.include_router( health_router, tags=["health"],)api_router.include_router(auth_router)
Because main.py already applies:
/api/v1
the final endpoint becomes:
POST /api/v1/auth/register
40. Start Quorentra
Run:
python -m uvicorn app.main:app --reload
Open:
http://127.0.0.1:8000/docs
You should now see:
auth
with:
POST /api/v1/auth/register
Quorentra now exposes its first end-user business operation.
41. Register the First User
In Swagger UI, open:
POST /api/v1/auth/register
Click:
Try it out
Use:
{ "email": "owner@example.com", "password": "VerySecurePassword123!", "display_name": "CRM Owner", "organization_name": "Acme Consulting"}
Execute.
Expected HTTP status:
201 Created
The response should resemble:
{ "user_id": "...", "organization_id": "...", "membership_id": "...", "email": "owner@example.com", "organization_name": "Acme Consulting", "role": "owner"}
This is a major milestone.
For the first time, Quorentra has accepted an actual user into the application.
42. Inspect PostgreSQL
Open pgAdmin.
Inspect:
users
You should see:
owner@example.com
but you should not see the original password.
Instead, the password_hash field should contain an Argon2 hash.
Inspect:
organizations
You should see:
Acme Consulting
with a slug similar to:
acme-consulting
Inspect:
memberships
You should see a relationship between the user and organization with:
role = owner
The onboarding transaction worked.
43. Verify Through SQL
In pgAdmin Query Tool:
SELECT u.email, o.name AS organization_name, o.slug, m.roleFROM memberships AS mJOIN users AS u ON u.id = m.user_idJOIN organizations AS o ON o.id = m.organization_id;
Expected result:
owner@example.comAcme Consultingacme-consultingowner
This query shows the complete tenant relationship created from one API request.
44. Test Duplicate Registration
Send the same registration request again:
{ "email": "owner@example.com", "password": "AnotherSecurePassword123!", "display_name": "Another Owner", "organization_name": "Another Company"}
Expected:
409 Conflict
with a response similar to:
{ "detail": "A user with this email already exists."}
Quorentra should not create another organization or membership.
This is important.
The failure occurs before the transaction creates any new records.
45. Verify Email Normalization
Try:
{ "email": "OWNER@EXAMPLE.COM", "password": "AnotherSecurePassword123!", "display_name": "Duplicate Owner", "organization_name": "Duplicate Company"}
Because the service normalizes the email to:
owner@example.com
the request should again return:
409 Conflict
This demonstrates why normalization belongs before uniqueness checks.
46. Test Organization Slug Collision
Register a second user:
{ "email": "second@example.com", "password": "SecondSecurePassword123!", "display_name": "Second Owner", "organization_name": "Acme Consulting"}
The email is unique, so registration should succeed.
But:
acme-consulting
already exists.
The service should therefore generate something similar to:
acme-consulting-7c92fa
The organization names can be identical.
The technical slugs remain unique.
47. Add Registration API Tests
Create:
tests/api/test_registration.py
From PowerShell:
New-Item tests\api\test_registration.py -ItemType File
Add:
from uuid import uuid4from fastapi.testclient import TestClientfrom app.main import appclient = TestClient(app)def test_register_user_and_organization() -> None: unique_value = uuid4().hex response = client.post( "/api/v1/auth/register", json={ "email": f"owner-{unique_value}@example.com", "password": "VerySecurePassword123!", "display_name": "Test Owner", "organization_name": "Test Organization", }, ) assert response.status_code == 201 data = response.json() assert data["email"] == ( f"owner-{unique_value}@example.com" ) assert data["organization_name"] == ( "Test Organization" ) assert data["role"] == "owner" assert data["user_id"] assert data["organization_id"] assert data["membership_id"]
This verifies the public HTTP workflow.
48. Test Password Is Not Returned
Add:
def test_registration_does_not_return_password() -> None: unique_value = uuid4().hex response = client.post( "/api/v1/auth/register", json={ "email": f"secure-{unique_value}@example.com", "password": "VerySecurePassword123!", "display_name": "Secure User", "organization_name": "Secure Organization", }, ) assert response.status_code == 201 data = response.json() assert "password" not in data assert "password_hash" not in data
This is a simple but useful security regression test.
49. Test Duplicate Email
Add:
def test_duplicate_email_is_rejected() -> None: unique_value = uuid4().hex email = f"duplicate-{unique_value}@example.com" payload = { "email": email, "password": "VerySecurePassword123!", "display_name": "Test Owner", "organization_name": "First Organization", } first_response = client.post( "/api/v1/auth/register", json=payload, ) assert first_response.status_code == 201 second_response = client.post( "/api/v1/auth/register", json={ **payload, "organization_name": "Second Organization", }, ) assert second_response.status_code == 409
This verifies our application-level uniqueness behavior.
50. Test Invalid Email
Add:
def test_invalid_email_is_rejected() -> None: response = client.post( "/api/v1/auth/register", json={ "email": "not-an-email", "password": "VerySecurePassword123!", "organization_name": "Test Organization", }, ) assert response.status_code == 422
Pydantic rejects the request before the application service runs.
51. Test Short Password
Add:
def test_short_password_is_rejected() -> None: unique_value = uuid4().hex response = client.post( "/api/v1/auth/register", json={ "email": f"short-{unique_value}@example.com", "password": "short", "organization_name": "Test Organization", }, ) assert response.status_code == 422
Again, invalid input never reaches the persistence layer.
52. Test Email Normalization
Add:
def test_email_is_normalized() -> None: unique_value = uuid4().hex local_part = f"normalize-{unique_value}" response = client.post( "/api/v1/auth/register", json={ "email": f"{local_part.upper()}@EXAMPLE.COM", "password": "VerySecurePassword123!", "organization_name": "Normalization Test", }, ) assert response.status_code == 201 assert response.json()["email"] == ( f"{local_part.lower()}@example.com" )
This verifies behavior through the actual API rather than testing only the helper function.
53. Verify the Password Hash in PostgreSQL
We should also verify that the stored password is actually hashed.
Create:
tests/integration/test_registration_persistence.py
Add:
from uuid import uuid4from app.core.security import verify_passwordfrom app.db.session import SessionLocalfrom app.modules.auth.schemas import RegisterRequestfrom app.modules.auth.service import AuthServicefrom app.modules.users.repository import UserRepositorydef test_registration_stores_password_hash() -> None: unique_value = uuid4().hex email = f"password-{unique_value}@example.com" plain_password = "VerySecurePassword123!" request = RegisterRequest( email=email, password=plain_password, display_name="Password Test", organization_name="Password Test Organization", ) with SessionLocal() as db: service = AuthService(db) service.register(request) users = UserRepository(db) user = users.get_by_email(email) assert user is not None assert user.password_hash != plain_password assert verify_password( plain_password, user.password_hash, )
This verifies the actual security behavior.
54. Test the Complete Relationship
We also want to verify that registration creates exactly the expected relationship.
For example:
from sqlalchemy import selectfrom app.modules.memberships.model import Membership
Then query the membership created for the returned user and organization.
Verify:
membership.user_id == user_idmembership.organization_id == organization_idmembership.role == owner
This gives us confidence that the API response corresponds to persisted state.
55. Testing Transaction Rollback
Atomicity is one of the most important requirements in Part 7.
We need confidence that:
UserOrganizationMembership
do not become partially persisted.
A simple way to test this later is to inject a repository failure during registration.
For example:
Create UserCreate OrganizationMembershipRepository.add() → exception
The expected result is:
ROLLBACK
with no new user or organization committed.
This is a better unit-level service test than deliberately corrupting the production database schema.
56. Why Rollback Testing Matters
Without transaction rollback:
Registration │ ├── User ✓ ├── Organization ✓ └── Membership ✗
could leave:
orphaned userorphaned organization
The application would then need repair logic.
Instead, we want:
Registration Failure │ ▼ROLLBACK │ ▼Database unchanged
This is a fundamental transactional guarantee.
57. A Better Transaction Pattern for Later
Our current service uses:
try: ... self.db.commit()except Exception: self.db.rollback() raise
This is clear and appropriate for the current MVP.
As application services grow, we may move toward explicit transaction context management such as:
with self.db.begin(): ...
or a unit-of-work abstraction.
But we should not introduce a unit-of-work framework before we have multiple complex transactional use cases.
The current approach is easy to understand and debug.
58. Run the Full Test Suite
Stop Uvicorn if necessary.
Run:
python -m pytest
The suite should now cover:
HealthTenant modelsDatabase constraintsRegistrationInput validationDuplicate emailEmail normalizationPassword hashing
All tests should pass.
Do not continue to login development while registration tests are failing.
59. Verify Database Migration State
Run:
python -m alembic current
Then:
python -m alembic history
The migration history should now conceptually include:
initialize database foundation ↓add organizations users memberships ↓add user password hash
This is the beginning of a meaningful application schema history.
60. Update Quorentra Version
Open:
app/core/constants.py
Change:
APP_VERSION = "0.0.4"
to:
APP_VERSION = "0.0.5"
Start:
python -m uvicorn app.main:app --reload
Open:
http://127.0.0.1:8000/api/v1/health
Expected:
{ "status": "healthy", "service": "quorentra-api", "version": "0.0.5", "database": "connected"}
61. Current Module Structure
The relevant backend architecture should now resemble:
app/│├── api/│ └── v1/│ ├── health.py│ ├── router.py│ └── schemas.py│├── core/│ ├── config.py│ ├── constants.py│ └── security.py│├── db/│ ├── base.py│ ├── dependencies.py│ ├── health.py│ ├── mixins.py│ ├── models.py│ └── session.py│└── modules/ │ ├── auth/ │ ├── __init__.py │ ├── exceptions.py │ ├── router.py │ ├── schemas.py │ └── service.py │ ├── memberships/ │ ├── constants.py │ ├── model.py │ └── repository.py │ ├── organizations/ │ ├── model.py │ ├── repository.py │ ├── service.py │ └── utils.py │ └── users/ ├── model.py ├── repository.py └── utils.py
This is beginning to look like a real modular application.
62. The Request Path
Our first real business request now follows:
Client │ ▼POST /api/v1/auth/register │ ▼FastAPI Router │ ▼Pydantic Validation │ ▼AuthService │ ├── UserRepository ├── OrganizationRepository └── MembershipRepository │ ▼ SQLAlchemy │ ▼ PostgreSQL
This architecture is much more important than the number of lines of code.
It establishes the pattern future Quorentra capabilities will follow.
63. The Module Dependency Direction
Notice the dependency direction:
HTTP ↓Auth Module ↓Domain Repositories ↓SQLAlchemy ↓PostgreSQL
The database does not know about FastAPI.
Repositories do not know about HTTP.
The authentication service does not return HTTP responses.
This separation will make it easier to expose the same business capabilities later through MCP.
64. Why This Matters for ChatGPT
Eventually a ChatGPT-native onboarding capability might invoke a Quorentra application service through an MCP tool.
The architecture could become:
ChatGPT │ ▼Quorentra MCP │ ▼Application Service │ ▼Domain Repositories │ ▼PostgreSQL
while the web application uses:
React │ ▼FastAPI │ ▼Application Service
Both interfaces can reuse the same application logic.
That is precisely why we are avoiding business logic inside HTTP routes.
65. What Registration Does Not Do Yet
Successful registration currently creates:
UserOrganizationOwner Membership
But it does not return:
access tokenrefresh tokensession cookie
The user therefore exists but is not yet authenticated for subsequent protected requests.
That is intentional.
Part 7 solves:
How does a user enter the system?
Part 8 will solve:
How does that user prove their identity on later requests?
Separating these concerns keeps each checkpoint understandable.
66. Security Properties Achieved
Part 7 establishes several important security properties:
✓ Passwords are never stored in plaintext✓ Password hashes are never returned by the API✓ Passwords have minimum length validation✓ Email addresses are normalized✓ Duplicate users are rejected✓ Registration is transactional✓ Tenant owner membership is explicit✓ HTTP errors are separated from application errors
This is still not the complete authentication security architecture.
But it is a solid registration foundation.
67. What We Deliberately Did Not Add
We still have no:
- login endpoint;
- JWT access tokens;
- refresh tokens;
- logout;
- current-user dependency;
- current-organization selection;
- tenant request context;
- permission enforcement;
- password reset;
- email verification;
- MFA;
- OAuth;
- Microsoft login;
- Google login.
Those are later capabilities.
For the MVO, the next requirement is straightforward:
A registered user must be able to log in.
68. Acceptance Criteria
Part 7 is complete when:
✓ pwdlib is installed✓ Argon2 password hashing works✓ password verification works✓ User has password_hash✓ password migration is applied✓ auth module exists✓ registration request schema exists✓ registration response schema exists✓ email validation works✓ password length validation works✓ email normalization works✓ organization slug generation works✓ slug collisions are handled✓ duplicate email detection works✓ application registration exceptions exist✓ AuthService coordinates onboarding✓ User is created✓ Organization is created✓ Owner membership is created✓ registration uses one transaction✓ failures trigger rollback✓ POST /api/v1/auth/register exists✓ successful registration returns 201✓ duplicate email returns 409✓ invalid input returns 422✓ password is never returned✓ password hash is verified in persistence tests✓ complete test suite passes✓ Quorentra reports version 0.0.5
Most importantly:
One API request must reliably create one complete tenant owner identity.
69. Quorentra 0.0.5
Our application status is now:
Repository ✓FastAPI ✓PostgreSQL ✓SQLAlchemy ✓Alembic ✓Organizations ✓Users ✓Memberships ✓Password hashing ✓Registration ✓Organization onboarding ✓Owner membership ✓Transaction boundary ✓Login -JWT -Current user -Tenant context -RBAC -React -Companies -Contacts -Opportunities -MCP -ChatGPT -AI -
The application has crossed another important boundary.
Previously, Quorentra could store domain data.
Now a user can actually join the platform.
70. Our First Vertical Slice
Parts 3 through 7 together now form the first meaningful backend slice:
Repository ↓FastAPI ↓PostgreSQL ↓Tenant Domain ↓Registration API
And the actual request path is:
Registration Request │ ▼ HTTP │ ▼ AuthService │ ┌────┼────┐ ▼ ▼ ▼ User Org Membership │ │ │ └────┼────┘ ▼ PostgreSQL
That is working application behavior rather than architecture on paper.
71. Why Login Is the Correct Next Step
We now have:
emailpassword_hash
for every registered user.
That gives us the information required to verify credentials.
The next workflow is therefore:
POST /api/v1/auth/login
with:
{ "email": "owner@example.com", "password": "VerySecurePassword123!"}
Quorentra will:
Normalize Email ↓Find User ↓Verify Password ↓Check User Active ↓Issue Access Token
That access token will then allow protected API requests.
72. Authentication Architecture Preview
The next architecture will become:
Login Request │ ▼AuthService │ ├── UserRepository │ └── Password Verification │ ▼ JWT Creation │ ▼ Access Token
Subsequent requests will use:
Authorization:Bearer <token>
FastAPI will then resolve:
Bearer Token ↓JWT Validation ↓User ID ↓Current User
This will give Quorentra authenticated identity.
73. But Authentication Is Not Tenant Authorization
It is important to keep the distinction clear.
A valid JWT proves:
This request belongs to User X.
It does not automatically prove:
User X may access Organization Y.
That requires:
User ↓Membership ↓Organization
Therefore the progression remains:
Authentication ↓Current User ↓Tenant Context ↓Membership Validation ↓Authorization
We will build these capabilities incrementally rather than hiding them inside one large security article.
74. Development Progress
The MVO roadmap now looks like:
Part 3 — Repository ✓ │Part 4 — FastAPI ✓ │Part 5 — PostgreSQL ✓ │Part 6 — Tenant Domain ✓ │Part 7 — Registration ✓ │Part 8 — Login + JWT ↓ │Part 9 — Tenant Context ↓ │Part 10 — Authorization ↓ │First CRM Module
This is the modular development strategy in practice.
Every article leaves Quorentra in a runnable state and adds one coherent capability.
75. Next Article
Login and JWT Authentication
We will introduce:
- login request and response schemas;
- password verification;
- inactive-user checks;
- JWT signing configuration;
- access-token generation;
- token expiration;
- token claims;
- bearer authentication;
- JWT validation;
- current-user resolution;
- protected API endpoints;
- authentication error handling;
- login integration tests;
- invalid-password tests;
- invalid-token tests;
- expired-token behavior.
The architecture will evolve from:
Registered User
to:
Registered User │ ▼Login │ ▼Credential Verification │ ▼JWT Access Token │ ▼Authenticated Request │ ▼Current User
This will give Quorentra its first authenticated request lifecycle.
After that, we can use the membership architecture from Part 6 to establish the current organization and tenant context.
That will bring us very close to the point where we can safely build the first actual CRM module.
Quorentra can now register users.
Next, Quorentra learns how to recognize them when they return.