Building Authentication, Organization Accounts, and Role-Based Access Control

Introduction

The AI Tender Opportunity Scanner can now:

  • Ingest tender opportunities
  • Analyze tenders with AI
  • Match opportunities to business profiles
  • Rank relevant tenders
  • Manage saved opportunities
  • Track bid pipeline stages
  • Generate analytics
  • Monitor deadlines
  • Send in-app and email notifications
  • Deliver alerts to Slack and Microsoft Teams

The application now supports most of the operational workflow required to discover and manage tender opportunities.

However, the platform still needs a secure identity and access-management layer.

Without authentication and authorization, the application cannot reliably determine:

  • Who is using the system
  • Which organization the user belongs to
  • Which tenders the user may access
  • Who may invite team members
  • Who may configure integrations
  • Who may manage business profiles
  • Who may update or delete pipeline records
  • Which data belongs to which customer

This becomes especially important when the platform evolves from a single-user prototype into a multi-user SaaS product.

In this article, we will build:

  • User registration
  • Secure login
  • Password hashing
  • Access tokens
  • Refresh tokens
  • Organization accounts
  • Team membership
  • Invitation workflows
  • Role-based access control
  • Protected FastAPI routes
  • Tenant-level data isolation
  • React authentication state
  • Protected frontend routes
  • Security testing

This creates the foundation for a secure multi-tenant AI Tender Opportunity Scanner.


Development Progress

Project Scope and Roadmap ████████████████████ 100%
MVP Definition ████████████████████ 100%
Technology Stack ████████████████████ 100%
System Architecture ████████████████████ 100%
Database Design ████████████████████ 100%
Backend Project Structure ████████████████████ 100%
SQLAlchemy Models ████████████████████ 100%
Alembic Migration ████████████████████ 100%
Repository Layer ████████████████████ 100%
CRUD Service Layer ████████████████████ 100%
Tender Ingestion Pipeline ████████████████████ 100%
AI Analysis Pipeline ████████████████████ 100%
AI Matching Engine ████████████████████ 100%
React Dashboard ████████████████████ 100%
Tender Detail Page ████████████████████ 100%
Saved Opportunities Workspace ████████████████████ 100%
Pipeline Analytics Dashboard ████████████████████ 100%
Notifications & Monitoring ████████████████████ 100%
Email Notifications ████████████████████ 100%
Slack & Teams Integrations ████████████████████ 100%
Authentication & Organizations ████████████████████ 100%
Multi-Tenant Data Isolation ░░░░░░░░░░░░░░░░░░░░ 0%

What We Are Building

The platform will support organization accounts containing multiple users.

Example:

Northstar Consulting
├── Ben Kemp
│ └── Organization Owner
├── Anna de Vries
│ └── Bid Manager
├── Mark Jansen
│ └── Contributor
└── Sophie Peters
└── Viewer

Each organization will have its own:

  • Business profiles
  • Tender matches
  • Saved opportunities
  • Pipeline records
  • Notifications
  • Analytics
  • Email preferences
  • Slack integrations
  • Microsoft Teams integrations
  • Team members

Users from one organization must never see data belonging to another organization.


Authentication Versus Authorization

These concepts are related but different.

Authentication answers:

Who are you?

Authorization answers:

What are you allowed to do?

Example:

Authentication:
Ben successfully logs in.
Authorization:
Ben may manage organization settings because he is an owner.

A secure system requires both.


Organization-Based Multi-Tenancy

The organization becomes the primary tenant boundary.

SaaS Platform
|
+---- Organization A
| |
| +---- Users
| +---- Tenders
| +---- Opportunities
| +---- Integrations
|
+---- Organization B
|
+---- Users
+---- Tenders
+---- Opportunities
+---- Integrations

Most organization-owned tables should include:

organization_id

This field is used to isolate customer data.


Authentication Architecture

React Login Form
|
v
FastAPI Authentication API
|
v
Authentication Service
|
+---- User Repository
+---- Password Hasher
+---- Token Service
|
v
PostgreSQL

Protected request flow:

React Request
|
v
Access Token
|
v
FastAPI Dependency
|
v
Load Current User
|
v
Check Organization and Role
|
v
Execute Route

Core Authentication Components

The authentication system will contain:

User model
Organization model
Organization membership model
Invitation model
Password hashing service
Token service
Authentication service
Authorization policies
FastAPI dependencies
Frontend authentication context

Keeping these responsibilities separate makes the system easier to test and maintain.


Organization Model

Create:

app/models/organization.py

Recommended fields:

id
name
slug
status
created_at
updated_at

Example:

from datetime import datetime
from enum import Enum
from sqlalchemy import (
DateTime,
Enum as SqlEnum,
Integer,
String,
)
from sqlalchemy.orm import Mapped, mapped_column
from app.database.base import Base
class OrganizationStatus(str, Enum):
ACTIVE = "active"
SUSPENDED = "suspended"
CANCELLED = "cancelled"
class Organization(Base):
__tablename__ = "organizations"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
)
name: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
slug: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
index=True,
)
status: Mapped[OrganizationStatus] = mapped_column(
SqlEnum(
OrganizationStatus,
name="organization_status",
),
default=OrganizationStatus.ACTIVE,
nullable=False,
index=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
)

Why Use an Organization Slug?

A slug is a URL-friendly identifier.

Example:

Organization name:
Northstar Consulting
Slug:
northstar-consulting

It can later support URLs such as:

/app/northstar-consulting/dashboard

The internal database ID should still remain the primary key.


User Model

Create:

app/models/user.py

Recommended fields:

id
email
hashed_password
first_name
last_name
is_active
email_verified
last_login_at
created_at
updated_at

Example:

class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
)
email: Mapped[str] = mapped_column(
String(320),
unique=True,
nullable=False,
index=True,
)
hashed_password: Mapped[str] = mapped_column(
String(255),
nullable=False,
)
first_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
)
last_name: Mapped[str] = mapped_column(
String(100),
nullable=False,
)
is_active: Mapped[bool] = mapped_column(
Boolean,
default=True,
nullable=False,
)
email_verified: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
onupdate=datetime.utcnow,
nullable=False,
)

Never store plain-text passwords.


Organization Membership Model

A user may eventually belong to more than one organization.

Instead of storing a single organization_id directly on the user, create a membership table.

Create:

app/models/organization_membership.py

Fields:

id
organization_id
user_id
role
status
joined_at
created_at

Organization Roles

Define initial roles:

owner
administrator
bid_manager
contributor
viewer

Example enum:

class OrganizationRole(str, Enum):
OWNER = "owner"
ADMINISTRATOR = "administrator"
BID_MANAGER = "bid_manager"
CONTRIBUTOR = "contributor"
VIEWER = "viewer"

Membership status:

class MembershipStatus(str, Enum):
ACTIVE = "active"
INVITED = "invited"
SUSPENDED = "suspended"
REMOVED = "removed"

Membership Model

class OrganizationMembership(Base):
__tablename__ = "organization_memberships"
__table_args__ = (
UniqueConstraint(
"organization_id",
"user_id",
name="uq_organization_user_membership",
),
)
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
)
organization_id: Mapped[int] = mapped_column(
ForeignKey(
"organizations.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
user_id: Mapped[int] = mapped_column(
ForeignKey(
"users.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
role: Mapped[OrganizationRole] = mapped_column(
SqlEnum(
OrganizationRole,
name="organization_role",
),
default=OrganizationRole.CONTRIBUTOR,
nullable=False,
index=True,
)
status: Mapped[MembershipStatus] = mapped_column(
SqlEnum(
MembershipStatus,
name="membership_status",
),
default=MembershipStatus.ACTIVE,
nullable=False,
index=True,
)
joined_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
)

Why Use a Membership Table?

A membership table supports:

  • Multiple users per organization
  • Multiple organizations per user
  • Organization-specific roles
  • Suspended memberships
  • Invitation states
  • Future external collaborators

Example:

Ben
├── Owner at Northstar Consulting
└── Viewer at Procurement Network Europe

The user’s permissions can differ by organization.


Role Permission Matrix

Define permissions clearly before writing authorization code.

Example:

PermissionOwnerAdministratorBid ManagerContributorViewer
View tendersYesYesYesYesYes
Save opportunitiesYesYesYesYesNo
Edit pipeline stagesYesYesYesYesNo
Submit bid decisionsYesYesYesNoNo
Manage business profilesYesYesYesNoNo
Invite usersYesYesNoNoNo
Manage integrationsYesYesNoNoNo
Change rolesYesYesNoNoNo
Delete organizationYesNoNoNoNo
View analyticsYesYesYesYesYes

This matrix becomes the reference for backend policies and frontend visibility.


Permission-Based Authorization

Roles are useful, but permission checks are more flexible.

Define permissions:

class Permission(str, Enum):
TENDER_VIEW = "tender:view"
OPPORTUNITY_CREATE = "opportunity:create"
OPPORTUNITY_UPDATE = "opportunity:update"
OPPORTUNITY_DELETE = "opportunity:delete"
BID_DECISION_MANAGE = "bid_decision:manage"
PROFILE_MANAGE = "profile:manage"
ANALYTICS_VIEW = "analytics:view"
MEMBER_INVITE = "member:invite"
MEMBER_MANAGE = "member:manage"
INTEGRATION_MANAGE = "integration:manage"
ORGANIZATION_MANAGE = "organization:manage"

Map roles to permissions:

ROLE_PERMISSIONS = {
OrganizationRole.OWNER: set(Permission),
OrganizationRole.ADMINISTRATOR: {
Permission.TENDER_VIEW,
Permission.OPPORTUNITY_CREATE,
Permission.OPPORTUNITY_UPDATE,
Permission.OPPORTUNITY_DELETE,
Permission.BID_DECISION_MANAGE,
Permission.PROFILE_MANAGE,
Permission.ANALYTICS_VIEW,
Permission.MEMBER_INVITE,
Permission.MEMBER_MANAGE,
Permission.INTEGRATION_MANAGE,
Permission.ORGANIZATION_MANAGE,
},
OrganizationRole.BID_MANAGER: {
Permission.TENDER_VIEW,
Permission.OPPORTUNITY_CREATE,
Permission.OPPORTUNITY_UPDATE,
Permission.BID_DECISION_MANAGE,
Permission.PROFILE_MANAGE,
Permission.ANALYTICS_VIEW,
},
OrganizationRole.CONTRIBUTOR: {
Permission.TENDER_VIEW,
Permission.OPPORTUNITY_CREATE,
Permission.OPPORTUNITY_UPDATE,
Permission.ANALYTICS_VIEW,
},
OrganizationRole.VIEWER: {
Permission.TENDER_VIEW,
Permission.ANALYTICS_VIEW,
},
}

Why Check Permissions Instead of Roles?

Bad:

if user.role == "administrator":
allow()

Better:

if Permission.MEMBER_INVITE in permissions:
allow()

Permission checks make future role changes easier.

You can create new roles without rewriting every route.


Password Hashing

Passwords must be transformed using a secure password-hashing algorithm.

Create:

app/security/password_hasher.py

Interface:

class PasswordHasher:
def hash_password(
self,
password: str,
) -> str:
...
def verify_password(
self,
plain_password: str,
hashed_password: str,
) -> bool:
...

The authentication service should depend on this abstraction rather than a specific library implementation.


Password Validation

Recommended registration rules:

Minimum length: 12 characters
Maximum length: Reasonable application limit
Reject known weak passwords
Allow password managers and passphrases
Do not require arbitrary symbol patterns

Example Pydantic schema:

class UserRegistrationRequest(BaseModel):
email: EmailStr
password: str = Field(
min_length=12,
max_length=128,
)
first_name: str = Field(
min_length=1,
max_length=100,
)
last_name: str = Field(
min_length=1,
max_length=100,
)
organization_name: str = Field(
min_length=2,
max_length=255,
)

Registration Workflow

User submits registration
|
v
Validate email and password
|
v
Check email uniqueness
|
v
Create user
|
v
Create organization
|
v
Create owner membership
|
v
Issue tokens
|
v
Send verification email

These database operations should run inside one transaction.

If organization creation fails, the user should not remain partially created.


Registration Service

Create:

app/services/registration_service.py

Example:

class RegistrationService:
def __init__(
self,
user_repository,
organization_repository,
membership_repository,
password_hasher,
token_service,
unit_of_work,
):
self.user_repository = user_repository
self.organization_repository = (
organization_repository
)
self.membership_repository = (
membership_repository
)
self.password_hasher = password_hasher
self.token_service = token_service
self.unit_of_work = unit_of_work

Registration method:

def register(
self,
data: UserRegistrationRequest,
):
existing_user = (
self.user_repository
.get_by_email(data.email.lower())
)
if existing_user:
raise EmailAlreadyRegisteredError()
with self.unit_of_work.transaction():
user = self.user_repository.create(
User(
email=data.email.lower(),
hashed_password=(
self.password_hasher
.hash_password(data.password)
),
first_name=data.first_name,
last_name=data.last_name,
)
)
organization = (
self.organization_repository.create(
Organization(
name=data.organization_name,
slug=self.generate_unique_slug(
data.organization_name
),
)
)
)
self.membership_repository.create(
OrganizationMembership(
organization_id=organization.id,
user_id=user.id,
role=OrganizationRole.OWNER,
status=MembershipStatus.ACTIVE,
joined_at=datetime.utcnow(),
)
)
return self.token_service.issue_token_pair(
user_id=user.id,
organization_id=organization.id,
)

Login Workflow

User submits email and password
|
v
Load user by email
|
v
Verify password
|
v
Check account status
|
v
Load active memberships
|
v
Select active organization
|
v
Issue token pair

Avoid revealing whether an email address exists.

Bad error:

No user exists with this email address.

Better:

Invalid email or password.

This reduces account-enumeration risk.


Authentication Service

Create:

app/services/authentication_service.py
def authenticate(
self,
email: str,
password: str,
):
user = self.user_repository.get_by_email(
email.lower()
)
if not user:
raise InvalidCredentialsError()
if not self.password_hasher.verify_password(
password,
user.hashed_password,
):
raise InvalidCredentialsError()
if not user.is_active:
raise AccountDisabledError()
memberships = (
self.membership_repository
.list_active_for_user(user.id)
)
if not memberships:
raise NoActiveOrganizationError()
membership = memberships[0]
self.user_repository.update_last_login(
user.id
)
return self.token_service.issue_token_pair(
user_id=user.id,
organization_id=(
membership.organization_id
),
membership_id=membership.id,
role=membership.role,
)

Access and Refresh Tokens

Use two token types:

Access token
Refresh token

Access token:

  • Short lifetime
  • Used for API requests
  • Contains limited identity claims

Refresh token:

  • Longer lifetime
  • Used only to obtain a new access token
  • Should be revocable
  • Should be rotated after use

Token Claims

An access token may contain:

{
"sub": "user:42",
"organization_id": 10,
"membership_id": 88,
"role": "bid_manager",
"token_type": "access",
"exp": 1787000000
}

Do not place sensitive personal or commercial data in token claims.

Token contents may be decoded by the client.


Token Service

Create:

app/security/token_service.py

Interface:

class TokenService:
def create_access_token(
self,
*,
user_id: int,
organization_id: int,
membership_id: int,
role: OrganizationRole,
) -> str:
...
def create_refresh_token(
self,
*,
user_id: int,
session_id: int,
) -> str:
...
def decode_access_token(
self,
token: str,
):
...

Refresh Token Sessions

Refresh tokens should be associated with server-side session records.

Create:

auth_sessions

Recommended fields:

id
user_id
refresh_token_hash
device_name
ip_address
user_agent
expires_at
revoked_at
created_at
last_used_at

Store a hash of the refresh token rather than the raw token.


Authentication Session Model

class AuthenticationSession(Base):
__tablename__ = "authentication_sessions"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
)
user_id: Mapped[int] = mapped_column(
ForeignKey(
"users.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
refresh_token_hash: Mapped[str] = mapped_column(
String(255),
nullable=False,
unique=True,
)
device_name: Mapped[str | None] = mapped_column(
String(255),
nullable=True,
)
ip_address: Mapped[str | None] = mapped_column(
String(64),
nullable=True,
)
user_agent: Mapped[str | None] = mapped_column(
Text,
nullable=True,
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
revoked_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
last_used_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
)

Refresh Token Rotation

When a refresh token is used:

Validate token
|
v
Load session
|
v
Revoke old refresh token
|
v
Create replacement token
|
v
Return new token pair

This reduces the damage caused by stolen refresh tokens.

A reused revoked refresh token may indicate token theft and should trigger session revocation.


Cookie Versus Browser Storage

For browser applications, refresh tokens should preferably be stored in secure cookies.

Recommended cookie properties:

HttpOnly
Secure
SameSite
Restricted path

The JavaScript application should not need direct access to the refresh token.

The access token may be kept in memory and renewed when required.

Avoid storing long-lived authentication secrets in ordinary browser storage.


Authentication API

Create:

app/api/routes/auth.py

Recommended endpoints:

POST /auth/register
POST /auth/login
POST /auth/refresh
POST /auth/logout
POST /auth/logout-all
GET /auth/me

Future endpoints:

POST /auth/verify-email
POST /auth/forgot-password
POST /auth/reset-password
POST /auth/change-password
GET /auth/sessions
DELETE /auth/sessions/{id}

Registration Endpoint

@router.post(
"/register",
response_model=AuthenticationResponse,
)
def register(
payload: UserRegistrationRequest,
db: Session = Depends(get_db),
):
service = build_registration_service(db)
return service.register(payload)

Login Endpoint

@router.post(
"/login",
response_model=AuthenticationResponse,
)
def login(
payload: LoginRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
):
service = build_authentication_service(db)
token_pair = service.authenticate(
email=payload.email,
password=payload.password,
ip_address=request.client.host,
user_agent=request.headers.get(
"user-agent"
),
)
set_refresh_cookie(
response,
token_pair.refresh_token,
)
return {
"access_token":
token_pair.access_token,
"token_type":
"bearer",
"expires_in":
token_pair.expires_in,
}

Current User Endpoint

@router.get(
"/me",
response_model=CurrentUserResponse,
)
def get_current_user_profile(
context: AuthContext = Depends(
get_auth_context
),
):
return context

Example response:

{
"user": {
"id": 42,
"email": "ben@example.com",
"first_name": "Ben",
"last_name": "Kemp"
},
"organization": {
"id": 10,
"name": "Northstar Consulting",
"slug": "northstar-consulting"
},
"membership": {
"role": "owner",
"permissions": [
"tender:view",
"opportunity:create",
"member:invite"
]
}
}

Authentication Context

Create a normalized context object.

class AuthContext(BaseModel):
user_id: int
organization_id: int
membership_id: int
role: OrganizationRole
permissions: set[Permission]

Protected routes can depend on this context.


FastAPI Authentication Dependency

Create:

app/api/dependencies/auth.py
def get_auth_context(
credentials: HTTPAuthorizationCredentials = Depends(
bearer_scheme
),
db: Session = Depends(get_db),
) -> AuthContext:
claims = token_service.decode_access_token(
credentials.credentials
)
membership = (
membership_repository
.get_active_membership(
membership_id=claims.membership_id,
user_id=claims.user_id,
organization_id=claims.organization_id,
)
)
if not membership:
raise HTTPException(
status_code=401,
detail="Invalid authentication context",
)
return AuthContext(
user_id=membership.user_id,
organization_id=(
membership.organization_id
),
membership_id=membership.id,
role=membership.role,
permissions=ROLE_PERMISSIONS[
membership.role
],
)

Do not trust token role claims without checking that the membership still exists and remains active.


Permission Dependency

Create a reusable authorization dependency.

def require_permission(
required_permission: Permission,
):
def dependency(
context: AuthContext = Depends(
get_auth_context
),
) -> AuthContext:
if (
required_permission
not in context.permissions
):
raise HTTPException(
status_code=403,
detail="Insufficient permissions",
)
return context
return dependency

Usage:

@router.post("/members/invite")
def invite_member(
payload: MemberInvitationCreate,
context: AuthContext = Depends(
require_permission(
Permission.MEMBER_INVITE
)
),
):
...

Authentication and Authorization Status Codes

Use status codes consistently.

401 Unauthorized
Authentication is missing or invalid.
403 Forbidden
Authentication succeeded, but permission is insufficient.
404 Not Found
The resource does not exist within the user's tenant.

For tenant-isolated resources, returning 404 may be safer than confirming that another organization owns the resource.


Tenant-Level Query Filtering

Authentication alone is not enough.

Every organization-owned query must include the tenant boundary.

Bad:

select(SavedOpportunity).where(
SavedOpportunity.id == opportunity_id
)

Better:

select(SavedOpportunity).where(
SavedOpportunity.id == opportunity_id,
SavedOpportunity.organization_id
== organization_id,
)

Never load the record first and rely only on frontend filtering.


Adding Organization IDs to Existing Models

The following tables should be reviewed:

business_profiles
tender_matches
saved_opportunities
notifications
notification_preferences
collaboration_integrations
notification_deliveries
analytics snapshots
bid decisions
internal notes

Some globally shared tender records may remain organization-independent:

tenders
tender_analysis
procurement_sources

Organization-specific data should reference the global tender through a foreign key.

Example:

Global tender
|
+---- Organization A match
+---- Organization B match
+---- Organization C match

Global and Tenant-Owned Data

A useful distinction is:

Global Data

Shared across the platform:

  • Public tender source
  • Tender title
  • Tender description
  • Public buyer
  • Deadline
  • Source URL
  • Public AI extraction

Tenant-Owned Data

Private to the organization:

  • Match score
  • Business profile
  • Saved status
  • Internal notes
  • Bid decision
  • Assigned owner
  • Pipeline stage
  • Private AI analysis
  • Collaboration configuration

This distinction reduces duplicated public data while preserving customer privacy.


Repository Tenant Scoping

Repositories should require organization_id.

Bad method:

def get_by_id(
self,
opportunity_id: int,
):
...

Better:

def get_by_id(
self,
*,
opportunity_id: int,
organization_id: int,
):
statement = (
select(SavedOpportunity)
.where(
SavedOpportunity.id
== opportunity_id,
SavedOpportunity.organization_id
== organization_id,
)
)
return self.db.scalar(statement)

Making the organization ID mandatory reduces accidental cross-tenant access.


Invitation Workflow

Organization owners and administrators need to invite team members.

Workflow:

Administrator enters email
|
v
Select role
|
v
Create invitation
|
v
Send invitation email
|
v
Recipient accepts
|
v
Create or link user
|
v
Activate membership

Invitation Model

Create:

app/models/organization_invitation.py

Fields:

id
organization_id
email
role
token_hash
status
invited_by_user_id
expires_at
accepted_at
created_at

Invitation statuses:

class InvitationStatus(str, Enum):
PENDING = "pending"
ACCEPTED = "accepted"
EXPIRED = "expired"
REVOKED = "revoked"

Invitation SQLAlchemy Model

class OrganizationInvitation(Base):
__tablename__ = "organization_invitations"
id: Mapped[int] = mapped_column(
Integer,
primary_key=True,
)
organization_id: Mapped[int] = mapped_column(
ForeignKey(
"organizations.id",
ondelete="CASCADE",
),
nullable=False,
index=True,
)
email: Mapped[str] = mapped_column(
String(320),
nullable=False,
index=True,
)
role: Mapped[OrganizationRole] = mapped_column(
SqlEnum(
OrganizationRole,
name="invitation_organization_role",
),
nullable=False,
)
token_hash: Mapped[str] = mapped_column(
String(255),
unique=True,
nullable=False,
)
status: Mapped[InvitationStatus] = mapped_column(
SqlEnum(
InvitationStatus,
name="invitation_status",
),
default=InvitationStatus.PENDING,
nullable=False,
index=True,
)
invited_by_user_id: Mapped[int] = mapped_column(
ForeignKey(
"users.id",
ondelete="CASCADE",
),
nullable=False,
)
expires_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
index=True,
)
accepted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=datetime.utcnow,
nullable=False,
)

Invitation Security

The invitation email contains a random token.

Store only a hash of that token in the database.

Workflow:

Generate random token
|
v
Store token hash
|
v
Send raw token in invitation URL
|
v
Hash submitted token
|
v
Compare with stored hash

Invitation URLs should expire.

Example:

https://app.example.com/invitations/accept?token=...

Invitation API

Recommended endpoints:

POST /organizations/current/invitations
GET /organizations/current/invitations
DELETE /organizations/current/invitations/{id}
POST /invitations/accept

Accepting an Invitation

There are two scenarios.

Existing User

User logs in
|
v
Accept invitation
|
v
Create organization membership

New User

Open invitation link
|
v
Create account and password
|
v
Verify invitation
|
v
Create membership

The invitation email must match the user’s account email unless an explicit administrative exception is supported.


Preventing Duplicate Memberships

Before accepting:

existing_membership = (
membership_repository
.get_by_user_and_organization(
user_id=user.id,
organization_id=invitation.organization_id,
)
)
if existing_membership:
raise MembershipAlreadyExistsError()

The database unique constraint provides the final protection.


Member Management API

Recommended endpoints:

GET /organizations/current/members
PATCH /organizations/current/members/{id}
DELETE /organizations/current/members/{id}

Supported operations:

  • Change role
  • Suspend membership
  • Reactivate membership
  • Remove member

Protecting the Last Owner

An organization must always have at least one active owner.

Prevent:

  • Removing the last owner
  • Downgrading the last owner
  • Suspending the last owner

Example service validation:

if membership.role == OrganizationRole.OWNER:
active_owner_count = (
membership_repository
.count_active_owners(
organization_id
)
)
if active_owner_count <= 1:
raise LastOwnerProtectionError()

Switching Organizations

A user may belong to multiple organizations.

Add endpoint:

POST /auth/switch-organization

Request:

{
"organization_id": 15
}

The backend must verify an active membership before issuing a replacement access token.


Organization Selection Flow

User logs in
|
v
One active organization?
|
+---- Yes: continue
|
+---- No:
|
v
Show organization selector

The selected organization becomes part of the authentication context.


Audit Logging

Security-sensitive actions should be recorded.

Examples:

User registered
User logged in
Login failed
Password changed
Refresh token rotated
User logged out
Invitation created
Invitation accepted
Membership role changed
Member removed
Integration credentials changed
Organization settings updated

Create:

audit_events

Recommended fields:

id
organization_id
actor_user_id
event_type
target_type
target_id
metadata
ip_address
created_at

Do not store passwords, tokens, or raw secrets in audit metadata.


Rate Limiting Authentication Endpoints

Protect:

/auth/login
/auth/register
/auth/forgot-password
/auth/refresh
/invitations/accept

Rate limits may be applied by:

  • IP address
  • Email address
  • Session
  • Account
  • Organization

Repeated failed login attempts should generate security logs.

Avoid permanent account lockouts that can be abused for denial-of-service attacks.


Email Verification

New accounts should verify their email address.

Workflow:

Create account
|
v
Generate verification token
|
v
Send email
|
v
User opens verification link
|
v
Mark email verified

Some actions may remain restricted until verification:

  • Inviting members
  • Creating external integrations
  • Starting a paid subscription
  • Exporting data

Password Reset

Password reset workflow:

User requests reset
|
v
Always return generic response
|
v
Generate short-lived reset token
|
v
Send reset email when account exists
|
v
Validate token
|
v
Set new password
|
v
Revoke active sessions

Generic response:

If an account exists for that email address, reset instructions have been sent.

Session Revocation

Users should be able to:

  • Log out of the current device
  • Log out of all devices
  • Revoke a specific session
  • Review recent sessions

Example session page:

Current Sessions
Chrome on Windows
The Hague, Netherlands
Active now
Mobile Safari
Last active yesterday
[Revoke]

Location should be treated as approximate and optional.


React Frontend Structure

Create:

src/
├── auth/
├── AuthContext.jsx
├── AuthProvider.jsx
├── ProtectedRoute.jsx
├── PermissionGate.jsx
└── tokenManager.js
├── pages/
├── LoginPage.jsx
├── RegisterPage.jsx
├── AcceptInvitationPage.jsx
├── OrganizationMembersPage.jsx
├── OrganizationSettingsPage.jsx
└── SessionManagementPage.jsx
├── components/
└── Auth/
├── LoginForm.jsx
├── RegistrationForm.jsx
├── OrganizationSelector.jsx
├── InviteMemberForm.jsx
├── MemberTable.jsx
├── RoleSelector.jsx
└── AccessDenied.jsx
├── hooks/
├── useAuth.js
├── usePermissions.js
└── useOrganizationMembers.js
└── services/
├── authService.js
└── organizationService.js

Authentication Context

The React authentication context manages:

Current user
Current organization
Current role
Permissions
Access token
Loading state
Login
Logout
Token refresh
Organization switching

Example state:

const [authState, setAuthState] = useState({
user: null,
organization: null,
role: null,
permissions: [],
accessToken: null,
loading: true,
});

Initial Authentication Check

When the application loads:

Load application
|
v
Request new access token using secure refresh cookie
|
+---- Success:
| Load current user
|
+---- Failure:
Show login page

The user should not briefly see protected content before authentication is confirmed.


Authentication Service

Create:

src/services/authService.js

Login:

export const login = async credentials => {
const response = await api.post(
"/auth/login",
credentials,
{
withCredentials: true,
}
);
return response.data;
};

Refresh:

export const refreshSession = async () => {
const response = await api.post(
"/auth/refresh",
{},
{
withCredentials: true,
}
);
return response.data;
};

Logout:

export const logout = async () => {
await api.post(
"/auth/logout",
{},
{
withCredentials: true,
}
);
};

API Client Interceptor

Attach the access token to API requests.

api.interceptors.request.use(config => {
const token = tokenManager.getAccessToken();
if (token) {
config.headers.Authorization =
`Bearer ${token}`;
}
return config;
});

When an access token expires:

Request receives 401
|
v
Attempt one token refresh
|
+---- Success:
| Retry original request
|
+---- Failure:
Clear authentication state
Redirect to login

Prevent multiple simultaneous refresh requests from creating a refresh storm.


Protected Route

Create:

src/auth/ProtectedRoute.jsx
export function ProtectedRoute({
children,
}) {
const {
user,
loading,
} = useAuth();
if (loading) {
return <AuthenticationLoading />;
}
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}

Permission Gate

Create:

src/auth/PermissionGate.jsx
export function PermissionGate({
permission,
children,
fallback = null,
}) {
const { permissions } = useAuth();
if (!permissions.includes(permission)) {
return fallback;
}
return children;
}

Usage:

<PermissionGate
permission="member:invite"
>
<InviteMemberButton />
</PermissionGate>

Frontend permission checks improve usability, but the backend must always enforce the same permission independently.


Login Page

The login form should contain:

Email address
Password
Remember this device
Forgot password
Sign in button
Registration link

Do not display detailed technical authentication errors.

Example:

Unable to sign in. Check your email and password and try again.

Registration Page

Fields:

First name
Last name
Work email
Password
Organization name
Terms acceptance

After registration:

Create account
|
v
Create organization
|
v
Sign user in
|
v
Open onboarding flow

Organization Members Page

Example:

---------------------------------------------------------
Team Members
---------------------------------------------------------
Ben Kemp
ben@example.com
Owner
Active
Anna de Vries
anna@example.com
Bid Manager
Active
Mark Jansen
mark@example.com
Contributor
Invited
[Invite Member]

Actions depend on permissions.


Invite Member Form

Fields:

Email address
Role
Optional personal message

Example:

<form onSubmit={handleInvite}>
<input
type="email"
name="email"
placeholder="colleague@example.com"
/>
<RoleSelector
value={role}
onChange={setRole}
/>
<button type="submit">
Send Invitation
</button>
</form>

Role Selector

Role descriptions should explain practical access.

Example:

Administrator
Can manage users, profiles, integrations, and opportunities.
Bid Manager
Can manage bid decisions, profiles, and pipeline activity.
Contributor
Can review and update opportunities.
Viewer
Can view tenders and analytics without changing data.

Users should not need to interpret internal permission codes.


Organization Settings

Recommended sections:

Organization profile
Members
Roles and access
Notification settings
Integrations
Security
Sessions
Billing
Data retention

Initially, this article focuses on profile, membership, and access control.


Hiding Versus Disabling Actions

When a user lacks permission, decide whether to:

  • Hide the action
  • Display it disabled
  • Display it with an explanation

Example:

Delete Organization
Only organization owners may perform this action.

For sensitive administrative actions, hiding may be appropriate.

For discoverability, disabled controls with explanations can be more helpful.


Alembic Migration

Generate:

alembic revision --autogenerate -m "add authentication and organizations"

Review carefully.

The migration should create:

organizations
users
organization_memberships
organization_invitations
authentication_sessions
audit_events

It may also add:

organization_id

to existing tenant-owned tables.

Apply:

alembic upgrade head

Migrating Existing Prototype Data

The application may already contain records without organizations.

Create a safe migration strategy:

1. Create default organization
2. Create initial owner user
3. Assign existing private records
4. Make organization_id non-null
5. Add tenant indexes and constraints

Do not immediately add a non-null organization field without assigning existing rows.


Tenant Indexes

Common indexes include:

organization_id
organization_id + status
organization_id + created_at
organization_id + tender_id
organization_id + pipeline_stage

Example:

Index(
"ix_saved_opportunities_org_stage",
"organization_id",
"pipeline_stage",
)

These improve organization-scoped queries.


Unique Constraints in Multi-Tenant Tables

A globally unique field may become organization-specific.

Before:

tender_id must be unique

After:

organization_id + tender_id must be unique

Example:

UniqueConstraint(
"organization_id",
"tender_id",
name="uq_saved_opportunity_org_tender",
)

Different organizations may save the same public tender independently.


Testing Authentication

Create:

tests/auth/
├── test_registration.py
├── test_login.py
├── test_refresh_tokens.py
├── test_logout.py
├── test_permissions.py
├── test_invitations.py
├── test_memberships.py
└── test_tenant_isolation.py

Registration Tests

Test:

  • New user registration
  • Organization creation
  • Owner membership creation
  • Password hashing
  • Duplicate email rejection
  • Duplicate organization slug handling
  • Transaction rollback
  • Token issuance
  • Verification email queueing

Login Tests

Test:

  • Valid credentials
  • Incorrect password
  • Unknown email
  • Disabled account
  • No active membership
  • Suspended organization
  • Last-login update
  • Session creation

Ensure invalid email and invalid password produce equivalent public responses.


Refresh Token Tests

Test:

  • Valid refresh
  • Expired refresh token
  • Revoked session
  • Token rotation
  • Reuse of old token
  • Logout revocation
  • Logout-all behavior

Permission Tests

Example:

def test_viewer_cannot_invite_member(
client,
viewer_access_token,
):
response = client.post(
"/organizations/current/invitations",
headers={
"Authorization":
f"Bearer {viewer_access_token}"
},
json={
"email": "new@example.com",
"role": "contributor",
},
)
assert response.status_code == 403

Tenant Isolation Tests

This is one of the most important test categories.

Example:

def test_user_cannot_read_other_organization_opportunity(
client,
organization_a_token,
organization_b_opportunity,
):
response = client.get(
(
"/saved-opportunities/"
f"{organization_b_opportunity.id}"
),
headers={
"Authorization":
f"Bearer {organization_a_token}"
},
)
assert response.status_code == 404

Repeat this pattern for:

  • Business profiles
  • Saved opportunities
  • Notifications
  • Analytics
  • Integrations
  • Members
  • Invitations
  • Internal notes

Invitation Tests

Test:

  • Administrator can invite
  • Contributor cannot invite
  • Invitation token is hashed
  • Expired invitation is rejected
  • Revoked invitation is rejected
  • Wrong email cannot accept
  • Duplicate membership is prevented
  • Invitation creates correct role
  • Invitation may be accepted only once

Last Owner Tests

Test:

Last owner cannot be removed
Last owner cannot be downgraded
Last owner cannot be suspended
Owner can transfer ownership
Additional owner can be removed safely

Frontend Tests

Test:

  • Login form validation
  • Registration form validation
  • Loading authentication state
  • Protected route redirection
  • Access-token refresh
  • Logout
  • Permission-controlled buttons
  • Member list rendering
  • Invitation submission
  • Role updates
  • Access denied states
  • Organization switching

End-to-End Authentication Scenario

A complete scenario:

1. Register a new account
2. Create an organization automatically
3. Verify owner membership
4. Open the tender dashboard
5. Invite a bid manager
6. Accept the invitation
7. Log in as the bid manager
8. Save and update an opportunity
9. Confirm the bid manager cannot manage integrations
10. Log in as a viewer
11. Confirm the viewer can read but not edit
12. Create a second organization
13. Confirm data remains isolated
14. Log out
15. Confirm protected routes are inaccessible

Security Checklist

At this stage, verify:

✔ Passwords are hashed
✔ Authentication errors are generic
✔ Access tokens have short lifetimes
✔ Refresh tokens are rotated
✔ Refresh tokens can be revoked
✔ Refresh tokens are not stored in plain text
✔ Protected routes require authentication
✔ Permissions are enforced by the backend
✔ Organization IDs scope private queries
✔ Cross-tenant resources return no data
✔ Invitation tokens expire
✔ Sensitive actions create audit events
✔ Authentication endpoints are rate-limited
✔ Secrets are excluded from logs
✔ The final owner cannot be removed
✔ Sessions can be revoked

Current Architecture

External Tender Sources
|
v
Tender Ingestion
|
v
AI Analysis
|
v
Personalized Matching
|
v
Organization-Scoped Workspace
|
+---- Recommendation Dashboard
+---- Saved Opportunities
+---- Analytics
+---- Notifications
+---- Email
+---- Slack and Teams
|
v
Authentication and Authorization
|
+---- Users
+---- Organizations
+---- Memberships
+---- Roles
+---- Permissions
+---- Sessions
+---- Invitations

The platform now understands both who the user is and which organizational data the user may access.


Current Deliverables

At this stage, we have designed:

✔ User accounts
✔ Organization accounts
✔ Organization memberships
✔ Role definitions
✔ Permission matrix
✔ Password hashing
✔ Registration workflow
✔ Login workflow
✔ Access tokens
✔ Refresh-token sessions
✔ Token rotation
✔ Logout and session revocation
✔ Protected FastAPI dependencies
✔ Permission-based authorization
✔ Organization-scoped repositories
✔ Tenant-level query filtering
✔ Team invitation workflow
✔ Member management
✔ Last-owner protection
✔ Organization switching
✔ Audit logging
✔ React authentication context
✔ Protected frontend routes
✔ Permission-aware components
✔ Organization member interface
✔ Authentication tests
✔ Authorization tests
✔ Tenant-isolation tests

The AI Tender Opportunity Scanner now has the identity and access foundation required for a secure multi-user SaaS platform.


Recommended Git Commit

git add .
git commit -m "Build authentication organizations and role based access control"

Push the changes:

git push origin main

What We Will Build Next

Authentication establishes the tenant boundary, but every existing service and repository must now apply that boundary consistently.

The next stage will strengthen multi-tenant architecture by introducing:

  • Organization-scoped database access
  • Tenant-aware service methods
  • Composite indexes
  • Data-isolation tests
  • Tenant-aware background jobs
  • Organization-specific AI usage tracking
  • Secure storage separation
  • Tenant lifecycle management

The next article in the development series is:

“Building Secure Multi-Tenant Data Isolation for the Tender SaaS Platform.”


Conclusion

Authentication, organization accounts, and role-based access control transform the AI Tender Opportunity Scanner from a single-user prototype into a secure collaborative SaaS application.

The authentication layer verifies user identity through secure password hashing, access tokens, refresh-token rotation, session revocation, and protected FastAPI dependencies. Organization memberships and permission-based authorization determine which actions each user may perform.

The organization model also creates the primary tenant boundary for business profiles, saved opportunities, analytics, notifications, and collaboration integrations. Every private query must now be scoped to the authenticated organization.

With users, organizations, invitations, roles, permissions, and sessions in place, the next step is applying rigorous tenant isolation across the entire database and service architecture.

Discover more from BidRadar

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

Continue reading