Quorentra

Quorentra CRM User Registration: Building from Zero — Part 7

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.

Quorentra User Registration: Building from Zero — Part 7
Quorentra User Registration: Building from Zero — Part 7

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 /users
POST /organizations
POST /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:

organizations
users
memberships
alembic_version

Verify:

python -m pytest

and:

python -m alembic current

before continuing.


6. Registration Requires Password Storage

Our current User model contains:

id
email
display_name
is_active
created_at
updated_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:

fastapi
uvicorn[standard]
sqlalchemy
psycopg[binary]
pydantic-settings
alembic
pwdlib[argon2]

8. Why Argon2?

Passwords should not be protected with general-purpose hashes such as:

SHA-256
SHA-512
MD5

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 PasswordHash
password_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\auth
New-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 File
New-Item app\modules\auth\schemas.py -ItemType File
New-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:

User
Organization
Membership
Password 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 UUID
from 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 characters
maximum 128 characters

We deliberately avoid complicated composition rules such as:

must contain:
1 uppercase
1 lowercase
1 digit
1 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:

password
password_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 re
def 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-consulting
acme-consulting-2
acme-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-consulting
acme-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):
pass
class 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 API
MCP Tool
Background Worker
Administrative 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 uuid4
from sqlalchemy.orm import Session
from app.core.security import hash_password
from app.modules.auth.exceptions import (
EmailAlreadyRegisteredError,
)
from app.modules.auth.schemas import (
RegisterRequest,
RegisterResponse,
)
from app.modules.memberships.constants import ROLE_OWNER
from app.modules.memberships.model import Membership
from app.modules.memberships.repository import (
MembershipRepository,
)
from app.modules.organizations.model import Organization
from app.modules.organizations.repository import (
OrganizationRepository,
)
from app.modules.organizations.utils import slugify
from app.modules.users.model import User
from app.modules.users.repository import UserRepository
from 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:

User
Organization
Membership

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 User
COMMIT
Create Organization
COMMIT
Create Membership
COMMIT

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, status
from sqlalchemy.orm import Session
from app.db.dependencies import get_db
from app.modules.auth.exceptions import (
EmailAlreadyRegisteredError,
)
from app.modules.auth.schemas import (
RegisterRequest,
RegisterResponse,
)
from app.modules.auth.service import AuthService
router = 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 APIRouter
from app.api.v1.health import router as health_router
from app.modules.auth.router import router as auth_router
api_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.role
FROM memberships AS m
JOIN users AS u
ON u.id = m.user_id
JOIN organizations AS o
ON o.id = m.organization_id;

Expected result:

owner@example.com
Acme Consulting
acme-consulting
owner

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 uuid4
from fastapi.testclient import TestClient
from app.main import app
client = 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 uuid4
from app.core.security import verify_password
from app.db.session import SessionLocal
from app.modules.auth.schemas import RegisterRequest
from app.modules.auth.service import AuthService
from app.modules.users.repository import UserRepository
def 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 select
from app.modules.memberships.model import Membership

Then query the membership created for the returned user and organization.

Verify:

membership.user_id == user_id
membership.organization_id == organization_id
membership.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:

User
Organization
Membership

do not become partially persisted.

A simple way to test this later is to inject a repository failure during registration.

For example:

Create User
Create Organization
MembershipRepository.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 user
orphaned 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:

Health
Tenant models
Database constraints
Registration
Input validation
Duplicate email
Email normalization
Password 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:

User
Organization
Owner Membership

But it does not return:

access token
refresh token
session 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:

email
password_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

In Part 8, we will build:

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.

Quorentra

Contact

Menu

Designed with WordPress

Discover more from Quorentra

Subscribe now to keep reading and get access to the full archive.

Continue reading