Quorentra

Quorentra CRM RBAC and Permissions: Building from Zero — Part 10

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.

Quorentra RBAC and Permissions - Building from Zero — Part 10
Quorentra RBAC and Permissions – Building from Zero — Part 10

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:

organizations
users
memberships

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:

companies
contacts
opportunities
tasks
settings
integrations
AI tools
MCP 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:

owner
admin
member
viewer

A permission describes a capability.

Examples:

companies.read
companies.create
companies.update
companies.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:

owner
admin
member
viewer

Their intended meanings are:

owner
Highest organization authority.
admin
Administrative access without ownership authority.
member
Normal CRM user.
viewer
Read-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.manage
AI configuration
integration administration
data export
security audit

Permission sets give us more flexibility.


9. Create the Authorization Module

Create:

app/modules/authorization/

From:

quorentra/backend

run:

mkdir app\modules\authorization
New-Item app\modules\authorization\__init__.py -ItemType File
New-Item app\modules\authorization\permissions.py -ItemType File
New-Item app\modules\authorization\roles.py -ItemType File
New-Item app\modules\authorization\policy.py -ItemType File
New-Item app\modules\authorization\dependencies.py -ItemType File
New-Item app\modules\authorization\exceptions.py -ItemType File
New-Item app\modules\authorization\router.py -ItemType File
New-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:

Companies
Contacts
Opportunities
Activities
Tasks
Documents
Reports
AI
MCP
Integrations
Organization Settings
Membership 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 StrEnum
class 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.read
companies.create
companies.update
companies.delete

This pattern is easy to understand and extend.

Later we might add:

activities.read
activities.create
tasks.read
tasks.create
tasks.assign
documents.read
documents.upload
documents.delete
reports.read
reports.export
ai.use
ai.configure
integrations.read
integrations.manage

The model scales naturally.


14. Define Role Constants

Open:

app/modules/authorization/roles.py

Add:

from enum import StrEnum
class 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:

owner
admin
member
viewer

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.delete
ownership.transfer
billing.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:

delete
membership administration
organization 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):
pass
class 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 Callable
from fastapi import Depends, HTTPException, status
from app.modules.authorization.permissions import (
Permission,
)
from app.modules.authorization.policy import (
role_has_permission,
)
from app.modules.tenants.context import TenantContext
from 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.id
tenant.user.id
tenant.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 UUID
from pydantic import BaseModel
class 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, Depends
from 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 TenantContext
router = 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:

User
Organization
Owner 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 user
but no membership in requested organization

Response:

403 Forbidden

Permission failure

Valid user
valid tenant membership
but 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 A
Owner 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 A
role = owner

and:

Organization B
role = 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:

FastAPI
MCP
background workers
automation engine
AI 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:

HTTP
FastAPI
React
ChatGPT
MCP

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_company
required_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.read
contacts.read
opportunities.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 records
documents
emails
meeting notes
embeddings
vector search
RAG

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:

Free
Pro
Business
Enterprise

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.delete
ownership.transfer
subscription.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 Manager
Account Executive
Support Agent
Finance User
Regional Manager

and assign individual permissions.

That is useful but introduces:

roles table
permissions table
role_permissions table
custom role UI
permission management APIs
migration 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:

Health
Database
Organizations
Users
Memberships
Registration
Password hashing
Login
JWT
Current user
Tenant context
Cross-tenant isolation
Role policy
Owner permissions
Admin permissions
Member permissions
Viewer permissions
Unknown roles
Permission enforcement
Cross-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 vocabulary
roles.py
Role vocabulary
policy.py
Role → Permission rules
dependencies.py
FastAPI enforcement
exceptions.py
Authorization errors
router.py
Temporary validation endpoint
schemas.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:

authentication
tenant isolation
authorization

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 3
Repository
Part 4
FastAPI
Part 5
PostgreSQL
Part 6
Tenant Domain
Part 7
Registration
Part 8
Authentication
Part 9
Tenant Context
Part 10
RBAC

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 /companies
POST /companies
GET /companies/{id}
PATCH /companies/{id}
DELETE /companies/{id}

with:

GET
companies.read
POST
companies.create
PATCH
companies.update
DELETE
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 company
WHERE id = company_id

alone.

Instead:

SELECT company
WHERE id = company_id
AND organization_id = tenant.organization.id

Likewise, list queries will become:

SELECT companies
WHERE 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:

register
login
tenant context
authorization 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 Company
Read Company
Update Company
Delete Company
List 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

In Part 11, we will build:

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.

Quorentra

Contact

Menu

Designed with WordPress

Discover more from Quorentra

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

Continue reading