Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM
Building role-based access control, permission policies, reusable authorization dependencies, tenant-aware enforcement, and automated permission tests.

1. Introduction
Quorentra now has two major security boundaries.
Part 8 established authentication:
JWT ↓Current User
Part 9 established tenant isolation:
Current User ↓Organization Selection ↓Membership Validation ↓TenantContext
Quorentra can therefore answer:
Who is making this request?
and:
Which organization is that user authorized to operate inside?
One question remains:
What is that user allowed to do inside the organization?
That is the responsibility of authorization.
In Part 10, we will implement a lightweight role-based access control system:
TenantContext ↓Membership Role ↓Permission Policy ↓Permission Check ↓Authorized Operation
By the end of this article, Quorentra will reach:
Quorentra 0.0.8 — Tenant-Aware Authorization
This is an important milestone.
After Part 10, we will have enough security infrastructure to begin building the first real CRM business module.
2. Authentication Is Not Authorization
These concepts must remain separate.
Authentication answers:
Who are you?
Tenant resolution answers:
Which organization are you operating inside?
Authorization answers:
What may you do there?
Together:
Authentication ↓User ↓Tenant Resolution ↓Organization + Membership ↓Authorization ↓Permission ↓Business Operation
Each layer has a different responsibility.
3. Why Membership Alone Is Not Enough
Part 9 proved that:
User A
belongs to:
Organization A
through a membership.
But memberships also contain:
role
For example:
Membership├── user_id├── organization_id└── role = owner
Another user might have:
role = viewer
Both users belong to the organization.
But they should not necessarily have the same capabilities.
For example:
Owner Viewer
Read companies ✓ ✓
Create company ✓ ✗
Update company ✓ ✗
Delete company ✓ ✗
Invite members ✓ ✗
Manage settings ✓ ✗
Membership establishes access to the tenant.
RBAC establishes capabilities inside the tenant.
4. Starting Checkpoint
Part 10 assumes Parts 6–9 are working.
We already have:
organizationsusersmemberships
The membership contains:
role
Authentication provides:
get_current_user()
Tenant resolution provides:
get_current_tenant()
which returns:
TenantContext( user=..., organization=..., membership=...,)
Before continuing, run:
python -m pytest
All existing tests should pass.
5. The Wrong Way to Implement Roles
A simple first attempt might be:
if tenant.membership.role != "owner": raise HTTPException(status_code=403)
Then another endpoint might contain:
if tenant.membership.role not in [ "owner", "admin",]: raise HTTPException(status_code=403)
Then another:
if tenant.membership.role == "viewer": raise HTTPException(status_code=403)
Soon, role logic becomes scattered throughout:
companiescontactsopportunitiestaskssettingsintegrationsAI toolsMCP handlers
That becomes difficult to reason about.
We need a centralized permission model.
6. Roles and Permissions Are Different
A role describes a category of user.
Examples:
owneradminmemberviewer
A permission describes a capability.
Examples:
companies.readcompanies.createcompanies.updatecompanies.delete
Roles should map to permissions:
Role ↓Permission Set
Endpoints and services should normally ask:
Does the current membership have this permission?
rather than:
Is the current membership an owner?
This is a much more extensible design.
7. MVP Roles
For the initial Quorentra CRM, we will support four roles:
owneradminmemberviewer
Their intended meanings are:
ownerHighest organization authority.adminAdministrative access without ownership authority.memberNormal CRM user.viewerRead-only CRM user.
This is enough for the MVP.
We do not need a sophisticated policy engine yet.
8. Role Hierarchy
Conceptually, the roles become progressively more restricted:
Owner │ ▼Admin │ ▼Member │ ▼Viewer
But we should not implement authorization merely by comparing hierarchy levels.
Why?
Because future permissions may not fit a perfect hierarchy.
For example:
billing.manageAI configurationintegration administrationdata exportsecurity audit
Permission sets give us more flexibility.
9. Create the Authorization Module
Create:
app/modules/authorization/
From:
quorentra/backend
run:
mkdir app\modules\authorizationNew-Item app\modules\authorization\__init__.py -ItemType FileNew-Item app\modules\authorization\permissions.py -ItemType FileNew-Item app\modules\authorization\roles.py -ItemType FileNew-Item app\modules\authorization\policy.py -ItemType FileNew-Item app\modules\authorization\dependencies.py -ItemType FileNew-Item app\modules\authorization\exceptions.py -ItemType FileNew-Item app\modules\authorization\router.py -ItemType FileNew-Item app\modules\authorization\schemas.py -ItemType File
The module becomes:
authorization/├── __init__.py├── dependencies.py├── exceptions.py├── permissions.py├── policy.py├── roles.py├── router.py└── schemas.py
10. Why Authorization Gets Its Own Module
Authorization will eventually be used by nearly every part of Quorentra.
For example:
CompaniesContactsOpportunitiesActivitiesTasksDocumentsReportsAIMCPIntegrationsOrganization SettingsMembership Management
It is therefore a cross-cutting application capability.
Keeping it centralized prevents each business module from inventing its own authorization model.
11. Define Permission Constants
Open:
app/modules/authorization/permissions.py
We will begin with permissions that cover the near-term platform and CRM roadmap.
Add:
from enum import StrEnumclass Permission(StrEnum): ORGANIZATION_READ = "organization.read" ORGANIZATION_MANAGE = "organization.manage" MEMBERS_READ = "members.read" MEMBERS_INVITE = "members.invite" MEMBERS_MANAGE = "members.manage" COMPANIES_READ = "companies.read" COMPANIES_CREATE = "companies.create" COMPANIES_UPDATE = "companies.update" COMPANIES_DELETE = "companies.delete" CONTACTS_READ = "contacts.read" CONTACTS_CREATE = "contacts.create" CONTACTS_UPDATE = "contacts.update" CONTACTS_DELETE = "contacts.delete" OPPORTUNITIES_READ = "opportunities.read" OPPORTUNITIES_CREATE = "opportunities.create" OPPORTUNITIES_UPDATE = "opportunities.update" OPPORTUNITIES_DELETE = "opportunities.delete"
We are defining the vocabulary of authorization.
12. Why Use StrEnum?
Permissions need to behave as both:
typed application values
and:
stable strings
For example:
Permission.COMPANIES_CREATE
maps to:
companies.create
This gives us:
- autocomplete;
- fewer spelling mistakes;
- centralized definitions;
- easy serialization;
- readable logs;
- testable policy rules.
13. Permission Naming Convention
We use:
resource.action
For example:
companies.readcompanies.createcompanies.updatecompanies.delete
This pattern is easy to understand and extend.
Later we might add:
activities.readactivities.createtasks.readtasks.createtasks.assigndocuments.readdocuments.uploaddocuments.deletereports.readreports.exportai.useai.configureintegrations.readintegrations.manage
The model scales naturally.
14. Define Role Constants
Open:
app/modules/authorization/roles.py
Add:
from enum import StrEnumclass Role(StrEnum): OWNER = "owner" ADMIN = "admin" MEMBER = "member" VIEWER = "viewer"
This gives us one authoritative role vocabulary.
15. Align Membership Roles
If Part 6 currently defines role constants separately, consolidate carefully.
We want:
owneradminmemberviewer
to mean exactly the same thing everywhere.
Avoid having:
memberships/constants.py
and:
authorization/roles.py
drift apart.
One clean approach is for membership validation to use the Role enum from the authorization module.
16. Why Role Belongs to Membership
The role remains attached to:
Membership
not:
User
because a user may have different roles in different organizations.
For example:
User Ben │ ├── Quorentra Labs │ role = owner │ └── Example Customer role = viewer
Therefore:
User
has identity.
Membership
has tenant-specific authority.
17. Define the Permission Policy
Open:
app/modules/authorization/policy.py
Import:
from app.modules.authorization.permissions import ( Permission,)from app.modules.authorization.roles import Role
Now create the role-to-permission mapping.
18. Owner Permissions
The owner should receive all currently defined permissions.
We can implement:
OWNER_PERMISSIONS = set(Permission)
This means the owner automatically receives every permission currently defined in the enum.
For the MVP, this is appropriate.
Later, if certain system-level operations should remain unavailable even to owners, those can be modeled separately.
19. Admin Permissions
Define:
ADMIN_PERMISSIONS = { Permission.ORGANIZATION_READ, Permission.ORGANIZATION_MANAGE, Permission.MEMBERS_READ, Permission.MEMBERS_INVITE, Permission.MEMBERS_MANAGE, Permission.COMPANIES_READ, Permission.COMPANIES_CREATE, Permission.COMPANIES_UPDATE, Permission.COMPANIES_DELETE, Permission.CONTACTS_READ, Permission.CONTACTS_CREATE, Permission.CONTACTS_UPDATE, Permission.CONTACTS_DELETE, Permission.OPPORTUNITIES_READ, Permission.OPPORTUNITIES_CREATE, Permission.OPPORTUNITIES_UPDATE, Permission.OPPORTUNITIES_DELETE,}
For now, admin is powerful.
Later we may reserve certain capabilities exclusively for owners.
Examples could include:
organization.deleteownership.transferbilling.manage
20. Member Permissions
Define:
MEMBER_PERMISSIONS = { Permission.ORGANIZATION_READ, Permission.MEMBERS_READ, Permission.COMPANIES_READ, Permission.COMPANIES_CREATE, Permission.COMPANIES_UPDATE, Permission.CONTACTS_READ, Permission.CONTACTS_CREATE, Permission.CONTACTS_UPDATE, Permission.OPPORTUNITIES_READ, Permission.OPPORTUNITIES_CREATE, Permission.OPPORTUNITIES_UPDATE,}
A normal member can work with CRM data but cannot perform destructive or administrative operations.
For the initial policy:
deletemembership administrationorganization management
remain restricted.
21. Viewer Permissions
Define:
VIEWER_PERMISSIONS = { Permission.ORGANIZATION_READ, Permission.MEMBERS_READ, Permission.COMPANIES_READ, Permission.CONTACTS_READ, Permission.OPPORTUNITIES_READ,}
The viewer can inspect CRM information but cannot modify it.
This gives us a simple read-only role.
22. Create the Role Policy Map
Now define:
ROLE_PERMISSIONS: dict[ Role, set[Permission],] = { Role.OWNER: OWNER_PERMISSIONS, Role.ADMIN: ADMIN_PERMISSIONS, Role.MEMBER: MEMBER_PERMISSIONS, Role.VIEWER: VIEWER_PERMISSIONS,}
The authorization architecture now has a single source of truth:
Role ↓ROLE_PERMISSIONS ↓Permissions
23. Create has_permission
Still in:
policy.py
add:
def has_permission( role: Role, permission: Permission,) -> bool: permissions = ROLE_PERMISSIONS.get( role, set(), ) return permission in permissions
This is the fundamental policy evaluation function.
24. Handle Role Strings Safely
The membership currently stores its role in PostgreSQL.
Depending on the model, it may arrive as:
"owner"
rather than:
Role.OWNER
Create:
def role_has_permission( role_value: str, permission: Permission,) -> bool: try: role = Role(role_value) except ValueError: return False return has_permission( role, permission, )
Unknown roles should fail closed.
25. Fail Closed
This is a fundamental security principle.
Suppose PostgreSQL somehow contains:
role = "super-special-user"
Quorentra should not guess what that means.
The result should be:
No recognized role ↓No permission ↓Access denied
Authorization should default to denial.
26. Add Authorization Exceptions
Open:
app/modules/authorization/exceptions.py
Add:
class AuthorizationError(Exception): passclass PermissionDeniedError( AuthorizationError): pass
Again, application authorization errors remain independent from HTTP.
27. Build the Permission Dependency
Now we need a reusable FastAPI mechanism.
We want endpoints to express:
Depends( require_permission( Permission.COMPANIES_CREATE ))
rather than manually implementing role logic.
Open:
app/modules/authorization/dependencies.py
Add:
from collections.abc import Callablefrom fastapi import Depends, HTTPException, statusfrom app.modules.authorization.permissions import ( Permission,)from app.modules.authorization.policy import ( role_has_permission,)from app.modules.tenants.context import TenantContextfrom app.modules.tenants.dependencies import ( get_current_tenant,)
28. Implement require_permission
Add:
def require_permission( permission: Permission,) -> Callable[..., TenantContext]: def dependency( tenant: TenantContext = Depends( get_current_tenant ), ) -> TenantContext: if not role_has_permission( tenant.membership.role, permission, ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied.", ) return tenant return dependency
This small function becomes a major architectural building block.
29. What the Permission Dependency Does
The flow becomes:
HTTP Request │ ▼Authentication │ ▼Current User │ ▼Tenant Resolution │ ▼TenantContext │ ▼Required Permission │ ▼Role Policy │ ├── Denied → 403 │ ▼Authorized TenantContext
The endpoint receives the tenant context only after authorization succeeds.
30. Authorization Becomes Declarative
Suppose we later build:
POST /api/v1/companies
The endpoint can declare:
tenant: TenantContext = Depends( require_permission( Permission.COMPANIES_CREATE ))
That tells us immediately:
This endpoint requires
companies.create.
The security requirement becomes visible in the endpoint definition.
That is much easier to review than hidden role conditionals.
31. Why Return TenantContext?
require_permission() could simply return:
True
But the endpoint will almost certainly need:
tenant.organization.idtenant.user.idtenant.membership
Therefore the permission dependency returns the already validated:
TenantContext
This avoids resolving the tenant twice.
32. Create a Temporary Authorization Test Endpoint
Before building Companies, we should prove RBAC independently.
Open:
app/modules/authorization/schemas.py
Add:
from uuid import UUIDfrom pydantic import BaseModelclass AuthorizationCheckResponse(BaseModel): organization_id: UUID role: str permission: str allowed: bool
This gives us a simple diagnostic response.
33. Create the Authorization Router
Open:
app/modules/authorization/router.py
Add:
from fastapi import APIRouter, Dependsfrom app.modules.authorization.dependencies import ( require_permission,)from app.modules.authorization.permissions import ( Permission,)from app.modules.authorization.schemas import ( AuthorizationCheckResponse,)from app.modules.tenants.context import TenantContextrouter = APIRouter( prefix="/authorization", tags=["authorization"],)
Now create a test endpoint.
34. Add a Permission-Protected Endpoint
Add:
router.get( "/companies/create-check", response_model=AuthorizationCheckResponse,)def check_company_create_permission( tenant: TenantContext = Depends( require_permission( Permission.COMPANIES_CREATE ) ),) -> AuthorizationCheckResponse: return AuthorizationCheckResponse( organization_id=tenant.organization.id, role=tenant.membership.role, permission=Permission.COMPANIES_CREATE, allowed=True, )
The endpoint becomes:
GET /api/v1/authorization/companies/create-check
It exists only to prove the authorization architecture before CRM functionality is introduced.
35. Register the Authorization Router
Open:
app/api/v1/router.py
Add:
from app.modules.authorization.router import ( router as authorization_router,)
Then:
api_router.include_router( authorization_router)
The API now includes:
/api/v1/auth/*/api/v1/tenant/*/api/v1/authorization/*
36. Test as the Organization Owner
Register a new account.
Registration creates:
UserOrganizationOwner Membership
Login.
Then call:
GET /api/v1/authorization/companies/create-check
with:
Authorization: Bearer <token>X-Organization-ID: <organization-id>
Expected:
200 OK
with something similar to:
{ "organization_id": "...", "role": "owner", "permission": "companies.create", "allowed": true}
The complete authorization pipeline works.
37. Test a Viewer
We also need a membership with:
role = viewer
For now, create or update a test membership directly through PostgreSQL or a test fixture.
Then call:
GET /api/v1/authorization/companies/create-check
Expected:
403 Forbidden
with:
{ "detail": "Permission denied."}
The viewer belongs to the organization but cannot create companies.
This distinction proves that RBAC is functioning.
38. Authentication Failure Versus Tenant Failure Versus Permission Failure
We now have three distinct security failures.
Authentication failure
No valid JWT
Response:
401 Unauthorized
Tenant failure
Valid userbut no membership in requested organization
Response:
403 Forbidden
Permission failure
Valid uservalid tenant membershipbut insufficient permission
Response:
403 Forbidden
These boundaries should remain conceptually distinct even when two produce the same HTTP status.
39. Unit Test the Policy
Create:
tests/unit/test_authorization_policy.py
Add:
from app.modules.authorization.permissions import ( Permission,)from app.modules.authorization.policy import ( role_has_permission,)def test_owner_can_create_company() -> None: assert role_has_permission( "owner", Permission.COMPANIES_CREATE, )def test_viewer_can_read_company() -> None: assert role_has_permission( "viewer", Permission.COMPANIES_READ, )def test_viewer_cannot_create_company() -> None: assert not role_has_permission( "viewer", Permission.COMPANIES_CREATE, )
These tests verify policy without involving HTTP or PostgreSQL.
40. Test Member Capabilities
Add:
def test_member_can_create_company() -> None: assert role_has_permission( "member", Permission.COMPANIES_CREATE, )def test_member_cannot_delete_company() -> None: assert not role_has_permission( "member", Permission.COMPANIES_DELETE, )
This captures an important MVP business rule:
member ↓normal CRM work ↓no destructive delete
41. Test Admin Capabilities
Add:
def test_admin_can_manage_members() -> None: assert role_has_permission( "admin", Permission.MEMBERS_MANAGE, )def test_admin_can_delete_company() -> None: assert role_has_permission( "admin", Permission.COMPANIES_DELETE, )
The policy becomes executable documentation.
42. Test Unknown Roles
Add:
def test_unknown_role_has_no_permissions() -> None: assert not role_has_permission( "unknown-role", Permission.COMPANIES_READ, )
This verifies our fail-closed policy.
Unknown authorization state must not accidentally grant access.
43. Test All Owner Permissions
Because owners receive:
set(Permission)
we can test every defined permission:
def test_owner_has_all_permissions() -> None: for permission in Permission: assert role_has_permission( "owner", permission, )
This test automatically covers future permission additions.
44. Test Viewer Is Read-Only
Add:
def test_viewer_cannot_modify_companies() -> None: denied_permissions = { Permission.COMPANIES_CREATE, Permission.COMPANIES_UPDATE, Permission.COMPANIES_DELETE, } for permission in denied_permissions: assert not role_has_permission( "viewer", permission, )
The read-only role now has an explicit regression test.
45. Add API Authorization Tests
Create:
tests/api/test_authorization.py
We need to verify not just the policy function, but the complete:
JWT ↓Tenant ↓Membership ↓Permission ↓Endpoint
pipeline.
46. Test Owner Authorization Through HTTP
Create a user through registration and login.
Then:
def test_owner_can_access_company_create_check( authenticated_owner,) -> None: response = client.get( "/api/v1/authorization/companies/create-check", headers={ "Authorization": ( f"Bearer {authenticated_owner['token']}" ), "X-Organization-ID": ( authenticated_owner[ "organization_id" ] ), }, ) assert response.status_code == 200
Use whatever fixture/helper structure your existing test suite already follows.
47. Test Viewer Authorization Through HTTP
Create a viewer membership.
Then call the same endpoint.
Expected:
assert response.status_code == 403
and:
assert response.json()["detail"] == ( "Permission denied.")
This verifies that membership alone is insufficient.
48. Cross-Tenant Protection Still Applies First
Suppose User A is an owner of Organization A.
They request Organization B.
Even though:
role = owner
inside Organization A, they have no authority inside Organization B.
The pipeline must stop at:
Membership Validation
before permission evaluation.
Conceptually:
Owner of Organization A │ ▼Request Organization B │ ▼Membership? │ └── No ↓ 403
Roles never cross tenant boundaries.
49. Test Cross-Tenant Owner Access
This deserves another permanent regression test.
Create:
Owner A → Organization AOwner B → Organization B
Login as Owner A.
Request:
X-Organization-ID: Organization-B-ID
against the authorization endpoint.
Expected:
403 Forbidden
Being an owner somewhere does not create global authority.
50. Role Is Always Evaluated from the Current Membership
This is why:
TenantContext
contains:
membership
The permission evaluator uses:
tenant.membership.role
not a global user role.
Therefore the same user can be:
Organization Arole = owner
and:
Organization Brole = viewer
and receive different permissions depending on the selected tenant.
51. Multi-Organization Authorization
Consider:
User │ ├── Organization A │ role = owner │ └── Organization B role = viewer
With:
X-Organization-ID: A
the user can:
companies.create ✓
With:
X-Organization-ID: B
the same user receives:
companies.create ✗companies.read ✓
No new JWT is necessary.
The authorization state comes from the membership associated with the active tenant.
52. Why This Architecture Scales
When we introduce Contacts, the endpoint can declare:
Permission.CONTACTS_CREATE
When we introduce Opportunities:
Permission.OPPORTUNITIES_UPDATE
When we introduce integrations:
Permission.INTEGRATIONS_MANAGE
The security architecture does not need to change.
We simply extend:
Permission
and update:
ROLE_PERMISSIONS
This is exactly what we want from a modular foundation.
53. Authorization Should Be Tested as Business Policy
Permission mapping is not merely implementation detail.
It represents business policy.
For example:
Viewer cannot create companies.Member cannot delete companies.Admin can manage members.Owner has all organization capabilities.
Those are product rules.
Tests should therefore explicitly capture them.
If a future developer accidentally grants:
companies.delete
to:
viewer
the test suite should fail immediately.
54. Avoid Permission Strings in Endpoints
Do not write:
require_permission( "companies.create")
Prefer:
require_permission( Permission.COMPANIES_CREATE)
This catches mistakes earlier.
For example:
company.create
versus:
companies.create
would otherwise be easy to mistype.
55. Avoid Direct Role Checks in CRM Modules
Future Company code should not contain:
if tenant.membership.role == "viewer":
Instead, authorization should already have happened at the boundary.
The service should receive an authorized context.
This keeps:
CompanyService
focused on company business logic.
56. Endpoint Authorization and Service Authorization
There is an architectural nuance here.
Checking permissions only in FastAPI routes is convenient, but application services may later be invoked from:
FastAPIMCPbackground workersautomation engineAI agents
Therefore long-term authorization should not exist only at the HTTP layer.
For the MVP, we establish the policy and dependency pattern first.
As MCP arrives, we can reuse the same policy evaluator from non-HTTP entry points.
57. The Policy Layer Is Interface-Neutral
This function:
role_has_permission( role, permission,)
knows nothing about:
HTTPFastAPIReactChatGPTMCP
That is intentional.
The FastAPI dependency uses it.
Later an MCP authorization adapter can use exactly the same function.
This is one of the most important design decisions in Part 10.
58. FastAPI Authorization
The REST path becomes:
React │ ▼FastAPI │ ▼get_current_user() │ ▼get_current_tenant() │ ▼require_permission() │ ▼Application Service
The framework adapter translates policy denial into:
403 Forbidden
59. Future MCP Authorization
The ChatGPT path can become:
ChatGPT │ ▼MCP Tool │ ▼Resolve Identity │ ▼Resolve Tenant │ ▼role_has_permission() │ ▼Application Service
The same authorization policy is reused.
We do not create an AI-specific permission model.
60. ChatGPT Does Not Receive Special Privileges
This rule should be explicit.
Suppose a viewer asks ChatGPT:
Create a new company called Contoso.
The model may correctly understand the intent.
But the execution pipeline must become:
Intent ↓companies.create ↓Current Membership ↓viewer ↓Permission Policy ↓DENIED
The result should be an authorization failure.
AI intelligence does not imply application authority.
61. Tool Authorization
This becomes particularly important with MCP tools.
A future tool might be:
create_company
The tool itself should correspond to a permission:
create_company ↓companies.create
Likewise:
delete_company ↓companies.delete
This gives Quorentra a clear bridge between AI tool execution and RBAC.
62. Future AI Tool Metadata
Later, we could model tools conceptually as:
Tool├── name├── description├── input schema└── required_permission
For example:
create_companyrequired_permission = companies.create
Then every AI-triggered action has an explicit authorization requirement.
This will be valuable when Quorentra becomes deeply ChatGPT-native.
63. Read Actions Need Authorization Too
It can be tempting to focus only on destructive operations.
But read access is also sensitive.
For example:
companies.readcontacts.readopportunities.read
protect customer data.
An unauthorized user should not be able to ask ChatGPT:
List all contacts in another organization.
The same tenant and permission boundaries must apply to reads.
64. Permissions and Future AI Retrieval
Eventually Quorentra will contain:
CRM recordsdocumentsemailsmeeting notesembeddingsvector searchRAG
Retrieval must remain tenant-aware and permission-aware.
The future pipeline should be:
User Question ↓TenantContext ↓Permission Check ↓Tenant-Scoped Retrieval ↓RAG Context ↓LLM
not:
Global Vector Search ↓LLM
Part 10 helps establish the authorization vocabulary that later AI retrieval can reuse.
65. Permissions Versus Feature Flags
Do not confuse:
permission
with:
feature flag
A permission answers:
Is this user authorized?
A feature flag answers:
Is this capability enabled?
Later we might have:
AI assistant enabled = true
but the user may still require:
ai.use
Both conditions may need to be true.
They are separate concerns.
66. Permissions Versus Subscription Plans
Likewise, authorization is not billing.
A future Quorentra plan might include:
FreeProBusinessEnterprise
A plan answers:
Has this organization purchased access to the capability?
A permission answers:
May this member use that capability?
For example:
Enterprise Plan +AI enabled +User has ai.use ↓AI capability available
Keeping these concepts separate will prevent architectural confusion later.
67. Permissions Versus Ownership
Some future operations may require ownership specifically.
For example:
organization.deleteownership.transfersubscription.cancel
Those could be modeled as permissions granted only to:
owner
We still do not need hard-coded:
if role == "owner"
The permission policy can express owner-only capabilities.
68. Add Owner-Only Permissions Later
For example:
ORGANIZATION_DELETE = ( "organization.delete")OWNERSHIP_TRANSFER = ( "organization.ownership.transfer")
Then grant them only to:
Role.OWNER
The authorization architecture already supports this.
No redesign is required.
69. Why We Are Not Building Custom Roles Yet
Some enterprise CRMs allow administrators to create arbitrary roles:
Sales ManagerAccount ExecutiveSupport AgentFinance UserRegional Manager
and assign individual permissions.
That is useful but introduces:
roles tablepermissions tablerole_permissions tablecustom role UIpermission management APIsmigration complexity
We do not need that for the MVO.
Our four static roles are enough to validate the architecture.
70. Modular Expansion Later
If custom roles become necessary, we can evolve from:
Role Enum ↓Static Permission Map
to:
Role Entity ↓RolePermission ↓Permission Registry
without changing the fundamental question:
Does this membership have this permission?
That is why the permission abstraction is valuable even with static roles.
71. No Database Migration Is Required
Part 10 uses the existing:
memberships.role
field.
Permissions are currently application policy.
Therefore:
Alembic migration required?
Answer:
No
This keeps the implementation lightweight.
Custom database-backed roles can come later if needed.
72. Run the Full Test Suite
Run:
python -m pytest
The suite should now cover:
HealthDatabaseOrganizationsUsersMembershipsRegistrationPassword hashingLoginJWTCurrent userTenant contextCross-tenant isolationRole policyOwner permissionsAdmin permissionsMember permissionsViewer permissionsUnknown rolesPermission enforcementCross-tenant authorization
All tests should pass.
73. Update Quorentra Version
Open:
app/core/constants.py
Change:
APP_VERSION = "0.0.7"
to:
APP_VERSION = "0.0.8"
Restart:
python -m uvicorn app.main:app --reload
Check:
GET /api/v1/health
Expected:
{ "status": "healthy", "service": "quorentra-api", "version": "0.0.8", "database": "connected"}
74. Current Authorization Structure
The relevant module now looks like:
authorization/├── __init__.py├── dependencies.py├── exceptions.py├── permissions.py├── policy.py├── roles.py├── router.py└── schemas.py
Responsibilities are clear:
permissions.py ↓Capability vocabularyroles.py ↓Role vocabularypolicy.py ↓Role → Permission rulesdependencies.py ↓FastAPI enforcementexceptions.py ↓Authorization errorsrouter.py ↓Temporary validation endpointschemas.py ↓API contracts
This is a compact but extensible RBAC architecture.
75. Complete Security Architecture
Quorentra now has three security layers.
Layer 1 — Authentication
JWT ↓Current User
Layer 2 — Tenant Isolation
Current User +Organization ↓Membership ↓TenantContext
Layer 3 — Authorization
TenantContext ↓Role ↓Permission ↓Authorized Operation
Combined:
HTTP Request │ ▼JWT Validation │ ▼Current User │ ▼Organization Selection │ ▼Membership Validation │ ▼TenantContext │ ▼Permission Evaluation │ ▼Application Service
This is the security foundation for the CRM.
76. The Same Foundation Supports ChatGPT
The future ChatGPT-native architecture can reuse exactly the same layers:
User │ ▼ChatGPT │ ▼Quorentra MCP │ ▼Identity │ ▼TenantContext │ ▼Permission Policy │ ▼Application Service │ ▼Repository │ ▼PostgreSQL
The AI interface does not bypass:
authenticationtenant isolationauthorization
It sits on top of them.
77. The Most Important AI Security Principle So Far
We can now formulate one of Quorentra’s core architectural principles:
The AI may interpret intent, but Quorentra determines authority.
For example:
User:"Delete Contoso."
ChatGPT may infer:
Intent = delete company
But Quorentra still evaluates:
Current Tenant? ↓companies.delete? ↓Allowed?
Only the application makes the final authorization decision.
78. What We Are Not Building Yet
Part 10 deliberately does not add:
- custom roles;
- database-backed permissions;
- field-level permissions;
- record ownership;
- territory security;
- team-based sharing;
- permission inheritance;
- ABAC;
- policy engines;
- OAuth scopes;
- feature flags;
- subscription entitlements;
- audit logs;
- approval workflows.
These may become valuable later.
They are not required for our first CRM vertical slice.
79. Acceptance Criteria
Part 10 is complete when:
✓ authorization module exists✓ Permission enum exists✓ Role enum exists✓ owner role exists✓ admin role exists✓ member role exists✓ viewer role exists✓ role-to-permission policy exists✓ owner receives all MVP permissions✓ admin permissions are defined✓ member permissions are defined✓ viewer permissions are read-only✓ unknown roles fail closed✓ has_permission() exists✓ role_has_permission() exists✓ require_permission() exists✓ permission checks use TenantContext✓ unauthorized operations return 403✓ authorized operations receive TenantContext✓ permission strings are centralized✓ CRM modules do not need direct role checks✓ policy is independent of FastAPI✓ policy can later be reused by MCP✓ owner permission tests pass✓ admin permission tests pass✓ member permission tests pass✓ viewer permission tests pass✓ unknown-role tests pass✓ HTTP permission tests pass✓ cross-tenant tests still pass✓ full test suite passes✓ Quorentra reports version 0.0.8
Most importantly:
Quorentra can now determine whether an authenticated member is authorized to perform a specific capability inside the selected organization.
80. Quorentra 0.0.8
Our application status is now:
Repository ✓FastAPI ✓PostgreSQL ✓SQLAlchemy ✓Alembic ✓Organizations ✓Users ✓Memberships ✓Password hashing ✓Registration ✓Organization onboarding ✓Login ✓JWT ✓Current user ✓Organization selection ✓Tenant context ✓Cross-tenant protection ✓Roles ✓Permissions ✓RBAC ✓Authorization enforcement ✓Companies -Contacts -Opportunities -React -MCP -ChatGPT -AI -
The platform can now answer:
Who are you?
Which organization are you operating inside?
and:
What are you allowed to do?
81. The Platform Foundation Is Ready
The series has progressed through:
Part 3Repository ✓ │Part 4FastAPI ✓ │Part 5PostgreSQL ✓ │Part 6Tenant Domain ✓ │Part 7Registration ✓ │Part 8Authentication ✓ │Part 9Tenant Context ✓ │Part 10RBAC ✓
We now have the foundation required for tenant-owned business data.
That means the next article should change character.
We are moving from:
Platform Foundation
into:
CRM Functionality
82. The First Real CRM Module
The logical first CRM entity is:
Company
A CRM fundamentally needs to know:
Which organizations does this customer organization do business with?
The Company entity can become the parent for:
Company │ ├── Contacts ├── Opportunities ├── Activities ├── Tasks └── Notes
This makes it an ideal first business module.
83. Company Must Be Tenant-Owned
The Company model will contain:
Company├── id├── organization_id├── name├── website├── industry├── phone├── created_at└── updated_at
The critical field is:
organization_id
Every company belongs to exactly one Quorentra organization.
This allows us to put Part 9’s tenant isolation into practice for the first time.
84. Company Operations Will Use RBAC
The API can expose:
GET /companiesPOST /companiesGET /companies/{id}PATCH /companies/{id}DELETE /companies/{id}
with:
GET ↓companies.readPOST ↓companies.createPATCH ↓companies.updateDELETE ↓companies.delete
The RBAC architecture from this article will immediately become useful.
85. The First Complete CRM Request
Part 11 will allow a request such as:
POST /api/v1/companies
to flow through:
JWT ↓Current User ↓TenantContext ↓companies.create ↓CompanyService ↓CompanyRepository ↓PostgreSQL
That will be our first tenant-owned, permission-protected CRM write operation.
86. Tenant-Scoped Company Queries
Company retrieval will not use:
SELECT companyWHERE id = company_id
alone.
Instead:
SELECT companyWHERE id = company_idAND organization_id = tenant.organization.id
Likewise, list queries will become:
SELECT companiesWHERE organization_id = tenant.organization.id
This puts our tenant-isolation architecture into actual business use.
87. The ChatGPT Connection Is Getting Closer
Once Companies exist, we will have the first meaningful application capability that ChatGPT could eventually use.
For example:
Show me my companies.
or:
Create a company called Contoso.
The future flow can become:
ChatGPT ↓MCP Tool ↓TenantContext ↓companies.read/create ↓CompanyService ↓PostgreSQL
This is where the ChatGPT-native architecture begins transitioning from architectural preparation into useful CRM behavior.
88. Why We Still Should Not Build MCP Next
It might be tempting to expose MCP immediately.
But MCP needs useful application capabilities to expose.
Right now we mainly have:
registerlogintenant contextauthorization checks
Those are infrastructure capabilities.
A more useful progression is:
Company ↓Contact ↓Opportunity ↓Minimal CRM ↓MCP ↓ChatGPT
Then ChatGPT has meaningful CRM operations available from day one.
89. The Modular Strategy Continues
We still are not trying to build the full CRM.
The next vertical slice is simply:
Create CompanyRead CompanyUpdate CompanyDelete CompanyList Companies
Then we prove:
tenant isolation+permissions+persistence+API
work together.
Only after that do we add Contacts.
This is exactly the modular approach the series was designed around.
90. Next Article
Company Management — The First CRM Module
We will introduce:
- Company domain model;
- tenant ownership;
- SQLAlchemy Company model;
- Alembic migration;
- company repository;
- company service;
- company request schemas;
- company response schemas;
- company creation;
- company listing;
- company retrieval;
- company updates;
- company deletion;
- tenant-scoped queries;
- RBAC-protected endpoints;
- duplicate handling;
- company API tests;
- cross-tenant company tests;
- preparation for Contacts;
- preparation for future ChatGPT company tools.
The architecture will evolve from:
JWT ↓Current User ↓TenantContext ↓Permission
into:
JWT ↓Current User ↓TenantContext ↓Permission ↓CompanyService ↓CompanyRepository ↓PostgreSQL
For the first time, all of the platform foundations we have built will converge on a genuine CRM capability.
Quorentra knows who the user is.
It knows which organization the user represents.
It knows what that user is permitted to do.
Next, Quorentra starts becoming an actual CRM.