Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM
Building the first complete CRM module with tenant-owned companies, CRUD operations, repository and service layers, RBAC enforcement, and cross-tenant isolation.

1. Introduction
Quorentra now has a substantial platform foundation.
We have built:
Repository ↓FastAPI ↓PostgreSQL ↓SQLAlchemy ↓Alembic ↓Organizations ↓Users ↓Memberships ↓Registration ↓Authentication ↓Tenant Context ↓RBAC
But Quorentra is not yet much of a CRM.
It cannot store a customer company.
It cannot list accounts.
It cannot update an organization we do business with.
That changes in Part 11.
We are going to build the first genuine CRM capability:
Company Management
By the end of this article, an authorized user will be able to:
Create CompanyRead CompanyList CompaniesUpdate CompanyDelete Company
while Quorentra automatically enforces:
Authentication+Tenant Isolation+Permissions
Quorentra will reach:
Quorentra 0.0.9 — Company Management
More importantly, we will establish the reusable pattern that future modules such as Contacts and Opportunities can follow.
2. Why Start with Companies?
Most CRMs revolve around several fundamental business entities:
CompaniesContactsOpportunitiesActivitiesTasks
We need to choose one as the first vertical slice.
Companies are the logical starting point because many future CRM records naturally relate to them.
Conceptually:
Company │ ├── Contacts │ ├── Opportunities │ ├── Activities │ ├── Tasks │ ├── Notes │ └── Documents
A company represents an external business organization with which the Quorentra tenant has a relationship.
Examples might include:
Contoso Ltd.Northwind TradersAdventure WorksFabrikam
The Quorentra organization itself is represented by:
Organization
External businesses stored in the CRM are represented by:
Company
These concepts must remain distinct.
3. Organization Versus Company
This distinction is important.
An Organization represents the Quorentra customer or workspace.
For example:
OrganizationQuorentra Consulting
Inside that workspace, users may manage:
CompaniesContosoFabrikamNorthwindAdventure Works
Therefore:
Organization │ ├── Company ├── Company ├── Company └── Company
The organization is the tenant.
Companies are CRM records owned by that tenant.
4. Tenant Ownership
Every company must belong to exactly one Quorentra organization.
Therefore our Company model needs:
organization_id
Conceptually:
Organization A │ ├── Company A1 ├── Company A2 └── Company A3Organization B │ ├── Company B1 └── Company B2
Organization A must never see:
Company B1Company B2
This gives us our first opportunity to apply Part 9’s tenant architecture to real CRM data.
5. The Most Important Company Security Rule
From this point forward, remember:
A company ID alone is never sufficient to retrieve a company.
Suppose the API receives:
GET /companies/{company_id}
A dangerous query would be:
SELECT *FROM companiesWHERE id = :company_id;
Instead, Quorentra must use:
SELECT *FROM companiesWHERE id = :company_idAND organization_id = :organization_id;
The organization ID comes from:
TenantContext
not directly from arbitrary client input.
6. The Complete Vertical Slice
Part 11 will create:
HTTP Request │ ▼Authentication │ ▼TenantContext │ ▼RBAC │ ▼Company Router │ ▼Company Service │ ▼Company Repository │ ▼SQLAlchemy │ ▼PostgreSQL
This is our first complete CRM request path.
7. Starting Checkpoint
Part 11 assumes Part 10 is working.
Run:
cd quorentra\backendpython -m pytest
All tests should pass.
Start the API:
python -m uvicorn app.main:app --reload
Verify:
GET /api/v1/health
Expected version:
0.0.8
We are now ready to introduce our first tenant-owned CRM entity.
8. Define the MVP Company Model
We deliberately want a small Company model.
For the first implementation:
Company├── id├── organization_id├── name├── website├── industry├── phone├── description├── created_at└── updated_at
That is enough to build useful CRM behavior without overengineering the entity.
9. What We Are Deliberately Not Adding Yet
Enterprise CRM company records can become enormous.
They may contain:
annual revenueemployee countbilling addressshipping addressVAT numbertax numberparent companysubsidiariesterritoriesaccount owneraccount statusaccount typelead sourcecustom fieldstagssocial profilesexternal IDsenrichment dataAI scores
We do not need these yet.
Our modular principle remains:
Build the smallest useful entity, prove the vertical slice, then expand.
10. Create the Companies Module
Create:
app/modules/companies/
From:
quorentra/backend
run:
mkdir app\modules\companiesNew-Item app\modules\companies\__init__.py -ItemType FileNew-Item app\modules\companies\model.py -ItemType FileNew-Item app\modules\companies\repository.py -ItemType FileNew-Item app\modules\companies\service.py -ItemType FileNew-Item app\modules\companies\schemas.py -ItemType FileNew-Item app\modules\companies\router.py -ItemType FileNew-Item app\modules\companies\exceptions.py -ItemType File
The module becomes:
companies/├── __init__.py├── exceptions.py├── model.py├── repository.py├── router.py├── schemas.py└── service.py
11. The Company Module Architecture
Responsibilities remain separated.
router.py ↓HTTP concernsschemas.py ↓API contractsservice.py ↓Business logicrepository.py ↓Persistence queriesmodel.py ↓SQLAlchemy entityexceptions.py ↓Domain/application errors
This is the same modular pattern we will reuse throughout Quorentra.
12. Create the Company SQLAlchemy Model
Open:
app/modules/companies/model.py
Add imports similar to:
from uuid import UUID, uuid4from sqlalchemy import ForeignKey, String, Textfrom sqlalchemy.orm import Mapped, mapped_columnfrom app.db.base import Base
Then define:
class Company(Base): __tablename__ = "companies" id: Mapped[UUID] = mapped_column( primary_key=True, default=uuid4, ) organization_id: Mapped[UUID] = mapped_column( ForeignKey( "organizations.id", ondelete="CASCADE", ), nullable=False, index=True, ) name: Mapped[str] = mapped_column( String(255), nullable=False, ) website: Mapped[str | None] = mapped_column( String(500), nullable=True, ) industry: Mapped[str | None] = mapped_column( String(255), nullable=True, ) phone: Mapped[str | None] = mapped_column( String(100), nullable=True, ) description: Mapped[str | None] = mapped_column( Text, nullable=True, )
If your existing project uses timestamp mixins, inherit from them rather than duplicating timestamp fields.
13. Add Timestamp Support
If Part 6 introduced something such as:
TimestampMixin
use:
class Company( TimestampMixin, Base,):
Then every company automatically receives:
created_atupdated_at
This is preferable to redefining timestamp behavior in every module.
14. Why organization_id Is Indexed
Most Company queries will contain:
organization_id
For example:
SELECT *FROM companiesWHERE organization_id = :organization_id;
As the dataset grows, indexing this column becomes important.
Tenant ownership is not merely metadata.
It is one of the primary query dimensions of the application.
15. Should Company Names Be Globally Unique?
No.
Two Quorentra organizations may both have a company called:
Contoso
Therefore this would be wrong:
UNIQUE(name)
Company uniqueness, if we enforce it, should be tenant-scoped.
Conceptually:
organization_id + normalized company name
But even that may be too restrictive in some real-world scenarios.
For the MVO, we will handle obvious duplicates at the application level without creating an overly aggressive database rule.
16. Register the Company Model
Alembic must know the model exists.
Depending on how Part 6 configured model discovery, update the central model import file.
For example:
app/db/models.py
Add:
from app.modules.companies.model import Company
If the import appears unused, that is acceptable.
Its purpose is to register the SQLAlchemy metadata before Alembic examines it.
17. Generate the Migration
From:
quorentra/backend
run:
alembic revision --autogenerate -m "create companies table"
Alembic should detect:
companies
Review the generated migration carefully.
Never blindly apply an autogenerated migration.
18. Inspect the Migration
The migration should conceptually create:
companies├── id├── organization_id├── name├── website├── industry├── phone├── description├── created_at└── updated_at
It should also create:
Foreign Keycompanies.organization_id ↓organizations.id
and an index for:
organization_id
Check both:
upgrade()
and:
downgrade()
19. Apply the Migration
Run:
alembic upgrade head
Then verify:
alembic current
The current revision should be the newly created migration.
20. Verify in pgAdmin
Open PostgreSQL through pgAdmin.
Navigate to:
Databases ↓quorentra ↓Schemas ↓public ↓Tables
You should now see:
companies
Inspect the columns and confirm:
organization_id
exists and references:
organizations.id
Quorentra now has its first CRM table.
21. Create Company Schemas
Open:
app/modules/companies/schemas.py
We need separate schemas for:
CreateUpdateResponse
Do not use the SQLAlchemy model directly as an API contract.
22. Company Create Schema
Add:
from pydantic import BaseModel, Fieldclass CompanyCreate(BaseModel): name: str = Field( min_length=1, max_length=255, ) website: str | None = Field( default=None, max_length=500, ) industry: str | None = Field( default=None, max_length=255, ) phone: str | None = Field( default=None, max_length=100, ) description: str | None = None
Notice what is missing:
organization_id
That is intentional.
23. Never Accept organization_id in Company Creation
A dangerous API would accept:
{ "organization_id": "some-other-tenant", "name": "Contoso"}
Instead, the client sends:
{ "name": "Contoso", "website": "https://example.com"}
The backend derives:
organization_id
from:
TenantContext
This prevents a client from creating records inside arbitrary organizations.
24. Company Update Schema
Add:
class CompanyUpdate(BaseModel): name: str | None = Field( default=None, min_length=1, max_length=255, ) website: str | None = Field( default=None, max_length=500, ) industry: str | None = Field( default=None, max_length=255, ) phone: str | None = Field( default=None, max_length=100, ) description: str | None = None
All fields are optional because:
PATCH
represents a partial update.
25. Company Response Schema
Add:
from datetime import datetimefrom uuid import UUIDfrom pydantic import ConfigDictclass CompanyResponse(BaseModel): model_config = ConfigDict( from_attributes=True ) id: UUID organization_id: UUID name: str website: str | None industry: str | None phone: str | None description: str | None created_at: datetime updated_at: datetime
from_attributes=True allows Pydantic to serialize the SQLAlchemy entity.
26. Should organization_id Be Returned?
For the MVP, yes.
Returning:
organization_id
makes tenant behavior explicit and easier to test.
Later, the frontend may not need to display it.
But including it in the API response is useful for:
debuggingintegration testingMCP contextAPI consumers
27. Create Company Exceptions
Open:
app/modules/companies/exceptions.py
Add:
class CompanyError(Exception): passclass CompanyNotFoundError( CompanyError): passclass CompanyAlreadyExistsError( CompanyError): pass
Again, the service layer should not need to know about HTTP status codes.
28. Build the Company Repository
Open:
app/modules/companies/repository.py
Add imports:
from uuid import UUIDfrom sqlalchemy import func, selectfrom sqlalchemy.orm import Sessionfrom app.modules.companies.model import Company
Then create:
class CompanyRepository: def __init__( self, db: Session, ) -> None: self.db = db
29. Create a Company
Add:
def create( self, company: Company,) -> Company: self.db.add(company) self.db.flush() self.db.refresh(company) return company
Notice that we do not necessarily commit here.
Transaction ownership should remain consistent with the pattern established in earlier parts.
If your service layer currently commits transactions, continue using that approach.
30. Tenant-Scoped Company Listing
Add:
def list_by_organization( self, organization_id: UUID,) -> list[Company]: statement = ( select(Company) .where( Company.organization_id == organization_id ) .order_by( Company.name.asc() ) ) return list( self.db.scalars(statement).all() )
This is our first real tenant-scoped CRM query.
31. Tenant-Scoped Company Lookup
Add:
def get_by_id_and_organization( self, company_id: UUID, organization_id: UUID,) -> Company | None: statement = select(Company).where( Company.id == company_id, Company.organization_id == organization_id, ) return self.db.scalar(statement)
This method is deliberately verbose.
The name makes the security property obvious:
get_by_id_and_organization
not:
get_by_id
32. Why We Avoid get_by_id
Suppose:
Company A
belongs to:
Organization A
and a user from Organization B somehow obtains its UUID.
If we call:
get_by_id(company_id)
we could accidentally return the record.
Instead:
get_by_id_and_organization( company_id, tenant.organization.id,)
produces no result.
The company behaves as though it does not exist within the current tenant.
33. Tenant-Scoped Name Lookup
For basic duplicate detection, add:
def get_by_name_and_organization( self, name: str, organization_id: UUID,) -> Company | None: statement = select(Company).where( Company.organization_id == organization_id, func.lower(Company.name) == name.lower(), ) return self.db.scalar(statement)
This makes duplicate detection tenant-specific.
34. Delete Company
Add:
def delete( self, company: Company,) -> None: self.db.delete(company) self.db.flush()
The repository does not decide whether deletion is authorized.
That has already been handled by:
RBAC
before the service executes.
35. Repository Responsibility
Our repository now answers persistence questions:
Create this company.List companies belonging to this organization.Find this company inside this organization.Find a same-name company inside this organization.Delete this company.
It does not decide:
Can this user create companies?
That belongs to authorization.
It also does not parse:
X-Organization-ID
That belongs to tenant resolution.
Responsibilities remain separated.
36. Build the Company Service
Open:
app/modules/companies/service.py
Import:
from uuid import UUIDfrom sqlalchemy.orm import Sessionfrom app.modules.companies.exceptions import ( CompanyAlreadyExistsError, CompanyNotFoundError,)from app.modules.companies.model import Companyfrom app.modules.companies.repository import ( CompanyRepository,)from app.modules.companies.schemas import ( CompanyCreate, CompanyUpdate,)from app.modules.tenants.context import TenantContext
Then:
class CompanyService: def __init__( self, db: Session, ) -> None: self.db = db self.repository = CompanyRepository(db)
37. Create Company Business Logic
Add:
def create_company( self, tenant: TenantContext, data: CompanyCreate,) -> Company: name = data.name.strip() existing = ( self.repository .get_by_name_and_organization( name=name, organization_id=( tenant.organization.id ), ) ) if existing is not None: raise CompanyAlreadyExistsError company = Company( organization_id=( tenant.organization.id ), name=name, website=data.website, industry=data.industry, phone=data.phone, description=data.description, ) company = self.repository.create( company ) self.db.commit() self.db.refresh(company) return company
The tenant owns the company automatically.
38. Why Tenant Context Enters the Service
Notice:
tenant: TenantContext
rather than:
organization_id: UUID
This communicates something important.
The service is not receiving an arbitrary organization identifier.
It is receiving an already validated application security context.
The company is therefore created for:
tenant.organization.id
39. Normalize Company Names
At minimum:
name = data.name.strip()
prevents records such as:
" Contoso "
from being stored unnecessarily.
We could add more sophisticated normalization later.
For now:
trim whitespacecase-insensitive duplicate check
is sufficient.
40. List Companies
Add:
def list_companies( self, tenant: TenantContext,) -> list[Company]: return ( self.repository .list_by_organization( tenant.organization.id ) )
This is intentionally simple.
The important part is that the tenant scope cannot be omitted.
41. Retrieve One Company
Add:
def get_company( self, tenant: TenantContext, company_id: UUID,) -> Company: company = ( self.repository .get_by_id_and_organization( company_id=company_id, organization_id=( tenant.organization.id ), ) ) if company is None: raise CompanyNotFoundError return company
A company outside the tenant produces:
CompanyNotFoundError
not:
CrossTenantCompanyError
That is intentional.
42. Do Not Reveal Cross-Tenant Records
Suppose Company X exists in another tenant.
Should the API say:
This company exists, but belongs to another organization.
No.
That leaks information.
Instead, within the current tenant:
Company X
simply does not exist.
Therefore:
404 Not Found
is appropriate.
43. Update Company
Add:
def update_company( self, tenant: TenantContext, company_id: UUID, data: CompanyUpdate,) -> Company: company = self.get_company( tenant, company_id, ) update_data = data.model_dump( exclude_unset=True ) if "name" in update_data: name = update_data["name"].strip() existing = ( self.repository .get_by_name_and_organization( name=name, organization_id=( tenant.organization.id ), ) ) if ( existing is not None and existing.id != company.id ): raise CompanyAlreadyExistsError update_data["name"] = name for field, value in update_data.items(): setattr( company, field, value, ) self.db.commit() self.db.refresh(company) return company
This gives us partial updates while preserving tenant scope.
44. Delete Company
Add:
def delete_company( self, tenant: TenantContext, company_id: UUID,) -> None: company = self.get_company( tenant, company_id, ) self.repository.delete(company) self.db.commit()
Again:
get_company()
ensures the company belongs to the current tenant before deletion.
45. Service Layer Security
Notice the service never does this:
Company( organization_id=data.organization_id)
Instead:
Company( organization_id=tenant.organization.id)
And it never retrieves:
repository.get_by_id(company_id)
Instead:
repository.get_by_id_and_organization( company_id, tenant.organization.id,)
Tenant isolation is therefore embedded into the business path.
46. Build the Company Router
Open:
app/modules/companies/router.py
Add imports:
from uuid import UUIDfrom fastapi import ( APIRouter, Depends, HTTPException, Response, status,)from sqlalchemy.orm import Sessionfrom app.db.dependencies import get_dbfrom app.modules.authorization.dependencies import ( require_permission,)from app.modules.authorization.permissions import ( Permission,)from app.modules.companies.exceptions import ( CompanyAlreadyExistsError, CompanyNotFoundError,)from app.modules.companies.schemas import ( CompanyCreate, CompanyResponse, CompanyUpdate,)from app.modules.companies.service import ( CompanyService,)from app.modules.tenants.context import ( TenantContext,)
47. Configure the Router
Add:
router = APIRouter( prefix="/companies", tags=["companies"],)
Our API will expose:
/api/v1/companies
48. Create Company Endpoint
Add:
router.post( "", response_model=CompanyResponse, status_code=status.HTTP_201_CREATED,)def create_company( data: CompanyCreate, tenant: TenantContext = Depends( require_permission( Permission.COMPANIES_CREATE ) ), db: Session = Depends(get_db),) -> CompanyResponse: service = CompanyService(db) try: return service.create_company( tenant, data, ) except CompanyAlreadyExistsError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( "A company with this name " "already exists." ), ) from exc
This is our first real permission-protected CRM write endpoint.
49. The Create Company Request Pipeline
A request now travels through:
POST /companies │ ▼JWT │ ▼Current User │ ▼X-Organization-ID │ ▼Membership │ ▼TenantContext │ ▼companies.create │ ▼CompanyService │ ▼CompanyRepository │ ▼PostgreSQL
This is the architecture we have been building toward.
50. List Companies Endpoint
Add:
router.get( "", response_model=list[CompanyResponse],)def list_companies( tenant: TenantContext = Depends( require_permission( Permission.COMPANIES_READ ) ), db: Session = Depends(get_db),) -> list[CompanyResponse]: service = CompanyService(db) return service.list_companies( tenant )
A viewer can use this endpoint because:
viewer ↓companies.read ✓
51. Get Company Endpoint
Add:
router.get( "/{company_id}", response_model=CompanyResponse,)def get_company( company_id: UUID, tenant: TenantContext = Depends( require_permission( Permission.COMPANIES_READ ) ), db: Session = Depends(get_db),) -> CompanyResponse: service = CompanyService(db) try: return service.get_company( tenant, company_id, ) except CompanyNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Company not found.", ) from exc
The tenant scope is applied inside the service and repository.
52. Update Company Endpoint
Add:
router.patch( "/{company_id}", response_model=CompanyResponse,)def update_company( company_id: UUID, data: CompanyUpdate, tenant: TenantContext = Depends( require_permission( Permission.COMPANIES_UPDATE ) ), db: Session = Depends(get_db),) -> CompanyResponse: service = CompanyService(db) try: return service.update_company( tenant, company_id, data, ) except CompanyNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Company not found.", ) from exc except CompanyAlreadyExistsError as exc: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail=( "A company with this name " "already exists." ), ) from exc
53. Delete Company Endpoint
Add:
router.delete( "/{company_id}", status_code=status.HTTP_204_NO_CONTENT,)def delete_company( company_id: UUID, tenant: TenantContext = Depends( require_permission( Permission.COMPANIES_DELETE ) ), db: Session = Depends(get_db),) -> Response: service = CompanyService(db) try: service.delete_company( tenant, company_id, ) except CompanyNotFoundError as exc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Company not found.", ) from exc return Response( status_code=status.HTTP_204_NO_CONTENT )
For the initial policy:
owner ✓admin ✓member ✗viewer ✗
can delete companies.
54. Register the Company Router
Open:
app/api/v1/router.py
Add:
from app.modules.companies.router import ( router as companies_router,)
Then:
api_router.include_router( companies_router)
Restart the server.
55. Inspect Swagger
Open:
http://127.0.0.1:8000/docs
You should now see:
companies
with:
POST /api/v1/companiesGET /api/v1/companiesGET /api/v1/companies/{company_id}PATCH /api/v1/companies/{company_id}DELETE /api/v1/companies/{company_id}
Quorentra finally exposes real CRM functionality.
56. Create the First Company
Login and obtain:
access_token
Also obtain your:
organization_id
Send:
POST /api/v1/companies
with headers:
Authorization: Bearer <token>X-Organization-ID: <organization-id>
and body:
{ "name": "Contoso", "website": "https://www.example.com", "industry": "Technology", "phone": "+31 20 000 0000", "description": "Example CRM account."}
Expected:
201 Created
57. Inspect the Response
The response should resemble:
{ "id": "...", "organization_id": "...", "name": "Contoso", "website": "https://www.example.com", "industry": "Technology", "phone": "+31 20 000 0000", "description": "Example CRM account.", "created_at": "...", "updated_at": "..."}
The critical point is:
organization_id
was assigned by the backend.
The client never supplied it in the request body.
58. Verify PostgreSQL
In pgAdmin, run:
SELECT id, organization_id, name, industryFROM companies;
You should see:
Contoso
associated with the correct Quorentra organization.
We now have persistent tenant-owned CRM data.
59. List Companies
Create a few companies.
Then send:
GET /api/v1/companies
Expected:
[ { "id": "...", "organization_id": "...", "name": "Contoso" }, { "id": "...", "organization_id": "...", "name": "Fabrikam" }]
Only companies belonging to the selected tenant should appear.
60. Retrieve a Company
Send:
GET /api/v1/companies/{company_id}
with the correct tenant context.
Expected:
200 OK
The repository executes the equivalent of:
company_id+organization_id
not merely:
company_id
61. Update a Company
Send:
PATCH /api/v1/companies/{company_id}
with:
{ "industry": "Cloud Services", "description": "Strategic cloud customer."}
Expected:
200 OK
Fields not included in the request remain unchanged.
62. Delete a Company
As an owner or admin, send:
DELETE /api/v1/companies/{company_id}
Expected:
204 No Content
Then:
GET /api/v1/companies/{company_id}
should return:
404 Not Found
The basic CRUD lifecycle now works.
63. Test Duplicate Company Names
Create:
Contoso
Then attempt to create:
contoso
inside the same organization.
Expected:
409 Conflict
because our initial duplicate rule is case-insensitive within the tenant.
64. Duplicate Names Across Tenants
Now create:
Contoso
inside Organization A.
Then create another:
Contoso
inside Organization B.
This should succeed.
Why?
Because CRM data is tenant-owned.
The duplicate rule applies only within:
organization_id
65. Test Viewer Read Access
A viewer should be able to call:
GET /companiesGET /companies/{id}
because the viewer has:
companies.read
Expected:
200 OK
This confirms read-only CRM access works.
66. Test Viewer Create Access
As a viewer, send:
POST /companies
Expected:
403 Forbidden
because:
viewer ↓companies.create ✗
The request should never reach company creation logic.
67. Test Member Create Access
As a member:
POST /companies
should succeed.
Our Part 10 policy grants:
member ↓companies.create ✓
This reflects normal CRM usage.
68. Test Member Delete Access
As a member:
DELETE /companies/{id}
should return:
403 Forbidden
because:
member ↓companies.delete ✗
The permission policy is now protecting actual business data.
69. The Critical Cross-Tenant Test
Now create:
Organization A ↓Company AOrganization B ↓Company B
Login as a member of Organization A.
Then request:
GET /companies/{company-b-id}
while sending:
X-Organization-ID: Organization-A-ID
Expected:
404 Not Found
This is one of the most important tests in the entire series.
70. Why Cross-Tenant Company Access Returns 404
The user is authorized to access Organization A.
Therefore tenant resolution succeeds.
The user also has:
companies.read
Therefore permission evaluation succeeds.
But the repository asks:
Find company Binside Organization A.
There is no such company.
Therefore:
404
This prevents the API from revealing that Company B exists elsewhere.
71. Test Cross-Tenant Update
Login as Organization A.
Attempt:
PATCH /companies/{company-b-id}
Expected:
404 Not Found
Company B must remain unchanged.
This proves tenant isolation applies to writes as well as reads.
72. Test Cross-Tenant Delete
Attempt:
DELETE /companies/{company-b-id}
from Organization A.
Expected:
404 Not Found
Then verify Company B still exists inside Organization B.
Cross-tenant destructive operations must be impossible.
73. Add Company API Tests
Create:
tests/api/test_companies.py
We want automated coverage for:
createlistretrieveupdatedeleteduplicatespermissionstenant isolation
This module becomes the first full business API test suite.
74. Test Company Creation
Conceptually:
def test_owner_can_create_company( authenticated_owner,) -> None: response = client.post( "/api/v1/companies", headers={ "Authorization": ( f"Bearer " f"{authenticated_owner['token']}" ), "X-Organization-ID": ( authenticated_owner[ "organization_id" ] ), }, json={ "name": "Contoso", "industry": "Technology", }, ) assert response.status_code == 201 data = response.json() assert data["name"] == "Contoso" assert data["organization_id"] == ( authenticated_owner[ "organization_id" ] )
This verifies tenant ownership is assigned correctly.
75. Test Company Listing
Create two companies in the same tenant.
Then:
response = client.get( "/api/v1/companies", headers=headers,)assert response.status_code == 200assert len(response.json()) == 2
Prefer isolated database fixtures so one test does not depend on another.
76. Test Company Retrieval
Create a company.
Then:
response = client.get( f"/api/v1/companies/{company_id}", headers=headers,)assert response.status_code == 200assert response.json()["id"] == company_id
This verifies the tenant-scoped lookup.
77. Test Company Update
Create:
Contoso
Then patch:
{ "industry": "Cloud Services"}
Verify:
assert response.status_code == 200assert ( response.json()["industry"] == "Cloud Services")
Also verify the company name remains unchanged.
78. Test Company Deletion
Create a company.
Delete it.
Then retrieve it.
Expected:
assert delete_response.status_code == 204assert get_response.status_code == 404
This covers the full lifecycle.
79. Test Duplicate Detection
Create:
Contoso
Then attempt:
CONTOSO
Expected:
assert response.status_code == 409
This confirms duplicate detection is case-insensitive.
80. Test Tenant List Isolation
Create:
Tenant A├── Company A1└── Company A2Tenant B└── Company B1
Call:
GET /companies
as Tenant A.
The result must contain:
A1A2
and never:
B1
This is an essential regression test.
81. Test Tenant Record Isolation
Create Company B under Tenant B.
Then as Tenant A:
response = client.get( f"/api/v1/companies/{company_b_id}", headers=tenant_a_headers,)assert response.status_code == 404
Keep this test permanently.
82. Test Viewer Permissions
Create a viewer membership.
Verify:
GET /companies → 200GET /companies/{id} → 200POST /companies → 403PATCH /companies/{id} → 403DELETE /companies/{id} → 403
This proves RBAC is integrated into the business module.
83. Test Member Permissions
Verify:
GET /companies → 200GET /companies/{id} → 200POST /companies → 201PATCH /companies/{id} → 200DELETE /companies/{id} → 403
Again, these are business policy tests, not merely technical tests.
84. Test Owner Permissions
Verify:
GET ✓CREATE ✓UPDATE ✓DELETE ✓
The owner should be able to perform the complete company lifecycle.
85. Run the Full Test Suite
Run:
python -m pytest
We now expect coverage across:
Platform├── health├── database├── configuration└── migrationsIdentity├── registration├── password hashing├── login├── JWT└── current userMulti-Tenancy├── organizations├── memberships├── tenant context└── cross-tenant isolationAuthorization├── roles├── permissions├── owner├── admin├── member└── viewerCRM└── Companies ├── create ├── list ├── retrieve ├── update ├── delete ├── duplicates ├── RBAC └── tenant isolation
All tests should pass.
86. Update Quorentra Version
Open:
app/core/constants.py
Change:
APP_VERSION = "0.0.8"
to:
APP_VERSION = "0.0.9"
Restart:
python -m uvicorn app.main:app --reload
Check:
GET /api/v1/health
Expected:
{ "status": "healthy", "service": "quorentra-api", "version": "0.0.9", "database": "connected"}
87. Quorentra 0.0.9
Our application status is now:
Repository ✓FastAPI ✓PostgreSQL ✓SQLAlchemy ✓Alembic ✓Organizations ✓Users ✓Memberships ✓Registration ✓Authentication ✓JWT ✓Tenant Context ✓Tenant Isolation ✓Roles ✓Permissions ✓RBAC ✓Companies ✓Company CRUD ✓Company Tenant Isolation ✓Company RBAC ✓Contacts -Opportunities -Activities -Tasks -React -MCP -ChatGPT -AI -
For the first time, Quorentra is not merely an application platform.
It contains a real CRM business capability.
88. What We Have Proven
Part 11 is more significant than the number of fields in the Company table suggests.
We have proven that the complete architecture works:
Client │ ▼FastAPI │ ▼Authentication │ ▼Tenant Resolution │ ▼RBAC │ ▼Business Service │ ▼Tenant-Aware Repository │ ▼SQLAlchemy │ ▼PostgreSQL
That pattern can now be reused.
89. The Reusable CRM Module Pattern
Future CRM modules can follow:
Model ↓Migration ↓Schemas ↓Repository ↓Service ↓Router ↓Permissions ↓Tests
For each tenant-owned entity we ask:
Who owns the record?What permissions protect it?How is tenant scope enforced?How is cross-tenant access tested?
This becomes our standard Quorentra module architecture.
90. Why This Is Better Than Building the Entire CRM First
We could have designed:
CompaniesContactsOpportunitiesTasksActivitiesEmailsMeetingsDocumentsAIWorkflowsDashboardsIntegrations
before running anything.
Instead, we now have:
A small working CRM
with one real business capability.
That is far more valuable.
We can run it.
We can test it.
We can inspect the database.
We can call the API.
We can break it.
We can improve it.
Then we add the next module.
91. We Now Have the Beginning of an MVO
The Minimum Viable Operation is taking shape.
A user can already conceptually:
Register ↓Create Organization ↓Login ↓Authenticate ↓Select Organization ↓Pass Authorization ↓Create Companies ↓Manage Companies
That is a genuine end-to-end application workflow.
The next objective is to make the CRM progressively more useful without destabilizing this working slice.
92. What Comes After Companies?
The next natural CRM entity is:
Contact
A company alone tells us:
Which business do we deal with?
A contact tells us:
Who do we deal with inside that business?
The relationship becomes:
Organization │ ▼Company │ ├── Contact ├── Contact └── Contact
This is the next important CRM relationship.
93. Contact Ownership
Contacts should also be directly tenant-owned.
Conceptually:
Contact├── id├── organization_id├── company_id├── first_name├── last_name├── email├── phone├── job_title├── created_at└── updated_at
Notice both:
organization_id
and:
company_id
This may appear redundant.
It is deliberate.
94. Why Contact Should Carry organization_id
We could derive tenant ownership through:
Contact ↓Company ↓Organization
But direct tenant ownership makes queries and security enforcement simpler.
For example:
SELECT *FROM contactsWHERE organization_id = :tenant_id;
instead of requiring a company join for every tenant check.
It also makes it easier to support contacts without companies later.
95. Cross-Tenant Relationship Validation
When creating a Contact under Company X, Quorentra must verify:
Contact.organization_id =Company.organization_id =TenantContext.organization.id
A client must never be able to create:
Tenant A Contact ↓Tenant B Company
This introduces our next important multi-tenant pattern:
Tenant-safe relationships between CRM entities.
96. Preparing for ChatGPT
Companies are also the first useful entity for the future ChatGPT interface.
A user could eventually ask:
Show me all companies.
That maps to:
companies.read
Or:
Create a company called Northwind Traders.
That maps to:
companies.create
Or:
Change Contoso’s industry to cloud consulting.
That maps to:
companies.update
The application capability already exists.
ChatGPT will eventually become another interface to it.
97. Future MCP Company Tools
The Company service naturally supports future MCP tools such as:
list_companiesget_companycreate_companyupdate_companydelete_company
Each tool can map directly to a permission:
list_companies ↓companies.readcreate_company ↓companies.createupdate_company ↓companies.updatedelete_company ↓companies.delete
We therefore do not need to redesign Company Management when MCP arrives.
98. Why We Should Not Add MCP Yet
We now have one useful business entity.
But a CRM consisting only of companies is still too limited for a compelling conversational experience.
A better minimum ChatGPT-native CRM slice is:
Companies ↓Contacts ↓Opportunities
Then ChatGPT can answer useful questions such as:
Who are my contacts at Contoso?
What opportunities are open with Northwind?
Create an opportunity for Fabrikam.
At that point, introducing MCP becomes much more valuable.
99. Our Near-Term Modular Roadmap
The next sequence should therefore remain disciplined:
Part 11Company Management ✓ │ ▼Part 12Contact Management │ ▼Part 13Opportunity Management │ ▼Part 14Minimal CRM API Integration │ ▼Part 15ChatGPT / MCP Foundation
This creates a compact but meaningful CRM core before adding the conversational interface.
100. Acceptance Criteria
Part 11 is complete when:
✓ companies module exists✓ Company SQLAlchemy model exists✓ companies table exists✓ Company belongs to Organization✓ organization_id is indexed✓ Alembic migration succeeds✓ CompanyCreate exists✓ CompanyUpdate exists✓ CompanyResponse exists✓ client cannot assign organization_id✓ CompanyRepository exists✓ company creation is tenant-owned✓ company listing is tenant-scoped✓ company retrieval is tenant-scoped✓ company update is tenant-scoped✓ company deletion is tenant-scoped✓ CompanyService exists✓ duplicate company detection exists✓ duplicate detection is tenant-specific✓ cross-tenant records appear nonexistent✓ POST /companies exists✓ GET /companies exists✓ GET /companies/{id} exists✓ PATCH /companies/{id} exists✓ DELETE /companies/{id} exists✓ companies.read is enforced✓ companies.create is enforced✓ companies.update is enforced✓ companies.delete is enforced✓ viewer is read-only✓ member can create and update✓ member cannot delete✓ owner can perform full CRUD✓ cross-tenant list isolation is tested✓ cross-tenant retrieval is tested✓ cross-tenant updates are blocked✓ cross-tenant deletion is blocked✓ full test suite passes✓ Quorentra reports version 0.0.9
Most importantly:
Quorentra now contains its first tenant-owned, permission-protected CRM business entity.
101. The Architecture After Part 11
Our architecture now looks like:
┌─────────────────┐
│ Client │
└────────┬────────┘
│
▼
┌─────────────────┐
│ FastAPI │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Authentication │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Tenant Context │
└────────┬────────┘
│
▼
┌─────────────────┐
│ RBAC │
└────────┬────────┘
│
▼
┌─────────────────┐
│ Company Service │
└────────┬────────┘
│
▼
┌───────────────────┐
│Company Repository │
└─────────┬─────────┘
│
▼
┌─────────────────┐
│ PostgreSQL │
└─────────────────┘
This is no longer merely infrastructure.
This is the beginning of Quorentra CRM.
102. The Next Architectural Step
The next module should reuse everything we just proved.
We do not need another authentication architecture.
We do not need another tenant model.
We do not need another RBAC system.
We simply extend the application:
Company │ ▼Contact
and reuse:
TenantContextPermissionsRepository PatternService PatternAPI PatternTesting Pattern
That is the payoff from the platform work in Parts 3–10.
103. Next Article
Contact Management — Adding People to the CRM
We will introduce:
- Contact domain model;
- tenant-owned contacts;
- optional Company relationship;
- Contact SQLAlchemy model;
- Company-to-Contact relationship;
- Alembic migration;
- contact repository;
- contact service;
- create schema;
- update schema;
- response schema;
- contact creation;
- contact listing;
- contact retrieval;
- contact updates;
- contact deletion;
- company-specific contact listing;
- tenant-safe company validation;
- contact permissions;
- duplicate email handling;
- cross-tenant relationship protection;
- contact API tests;
- preparation for Opportunity Management;
- preparation for ChatGPT contact tools.
The CRM model will evolve from:
Organization │ ▼Companies
to:
Organization │ ▼Companies │ ▼Contacts
And the application will evolve from:
AuthenticatedTenant-AwareAuthorizedCompany-Aware
to:
AuthenticatedTenant-AwareAuthorizedCompany-AwareContact-Aware
Part 11 is the point where Quorentra crosses from building the platform to building the product.
From here, every new module should leave us with a runnable, testable, incrementally more useful CRM.
Next, Quorentra learns about the people behind the companies.