major checkpoint

This commit is contained in:
2026-01-08 15:59:53 +10:30
parent 211854ca0a
commit 5e060f267d
33 changed files with 8088 additions and 43 deletions
+10
View File
@@ -7,6 +7,11 @@ from app.models.authentication_method import AuthenticationMethod
from app.models.session import Session
from app.models.audit_log import AuditLog
from app.models.oidc_client import OIDCClient
from app.models.oidc_authorization_code import OIDCAuthCode
from app.models.oidc_refresh_token import OIDCRefreshToken
from app.models.oidc_session import OIDCSession
from app.models.oidc_token_metadata import OIDCTokenMetadata
from app.models.oidc_audit_log import OIDCAuditLog
__all__ = [
"BaseModel",
@@ -17,4 +22,9 @@ __all__ = [
"Session",
"AuditLog",
"OIDCClient",
"OIDCAuthCode",
"OIDCRefreshToken",
"OIDCSession",
"OIDCTokenMetadata",
"OIDCAuditLog",
]
+231
View File
@@ -0,0 +1,231 @@
"""OIDC Audit Log model for comprehensive OIDC event tracking."""
from datetime import datetime
from app.extensions import db
from app.models.base import BaseModel
class OIDCAuditLog(BaseModel):
"""OIDC Audit Log model for comprehensive OIDC event tracking.
This model logs all OIDC-related events for security, compliance,
and debugging purposes.
"""
__tablename__ = "oidc_audit_logs"
# Event type categorization
event_type = db.Column(db.String(100), nullable=False, index=True)
# Client and User references
client_id = db.Column(
db.String(255), db.ForeignKey("oidc_clients.id"), nullable=True, index=True
)
user_id = db.Column(
db.String(36), db.ForeignKey("users.id"), nullable=True, index=True
)
# Event outcome
success = db.Column(db.Boolean, default=True, nullable=False, index=True)
# Error details (for failed events)
error_code = db.Column(db.String(100), nullable=True)
error_description = db.Column(db.Text, nullable=True)
# Request context
ip_address = db.Column(db.String(45), nullable=True, index=True)
user_agent = db.Column(db.Text, nullable=True)
request_id = db.Column(db.String(36), nullable=True, index=True)
# Additional event metadata
event_metadata = db.Column(db.JSON, nullable=True)
# Relationships
client = db.relationship("OIDCClient", back_populates="audit_logs")
user = db.relationship("User", back_populates="oidc_audit_logs")
def __repr__(self):
"""String representation of OIDCAuditLog."""
status = "success" if self.success else "failed"
return f"<OIDCAuditLog event={self.event_type} status={status} client={self.client_id}>"
@classmethod
def log_event(cls, event_type, client_id=None, user_id=None, success=True,
error_code=None, error_description=None, ip_address=None,
user_agent=None, request_id=None, event_metadata=None):
"""Log an OIDC event.
Args:
event_type: Type of event (e.g., "authorization_request", "token_issue")
client_id: The OIDC client ID
user_id: The user ID
success: Whether the event was successful
error_code: Error code if event failed
error_description: Error description if event failed
ip_address: Client IP address
user_agent: Client user agent
request_id: Request ID for correlation
event_metadata: Additional event metadata
Returns:
OIDCAuditLog instance
"""
log = cls(
event_type=event_type,
client_id=client_id,
user_id=user_id,
success=success,
error_code=error_code,
error_description=error_description,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
event_metadata=event_metadata,
)
db.session.add(log)
db.session.commit()
return log
@classmethod
def log_authorization_request(cls, client_id, user_id, redirect_uri, scope,
ip_address=None, user_agent=None, request_id=None,
success=True, error_code=None, error_description=None):
"""Log an authorization request event."""
return cls.log_event(
event_type="authorization_request",
client_id=client_id,
user_id=user_id,
success=success,
error_code=error_code,
error_description=error_description,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
event_metadata={
"redirect_uri": redirect_uri,
"scope": scope,
}
)
@classmethod
def log_token_issue(cls, client_id, user_id, token_type,
ip_address=None, user_agent=None, request_id=None):
"""Log a token issuance event."""
return cls.log_event(
event_type="token_issue",
client_id=client_id,
user_id=user_id,
success=True,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
event_metadata={"token_type": token_type}
)
@classmethod
def log_token_revocation(cls, client_id, user_id, token_type, reason=None,
ip_address=None, user_agent=None, request_id=None):
"""Log a token revocation event."""
return cls.log_event(
event_type="token_revocation",
client_id=client_id,
user_id=user_id,
success=True,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
event_metadata={
"token_type": token_type,
"reason": reason,
}
)
@classmethod
def log_authentication_failure(cls, client_id, error_code, error_description,
ip_address=None, user_agent=None, request_id=None):
"""Log an authentication failure event."""
return cls.log_event(
event_type="authentication_failure",
client_id=client_id,
success=False,
error_code=error_code,
error_description=error_description,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
)
@classmethod
def get_events_for_user(cls, user_id, limit=100):
"""Get audit events for a user.
Args:
user_id: The user ID
limit: Maximum number of events to return
Returns:
List of OIDCAuditLog instances
"""
return cls.query.filter_by(user_id=user_id, deleted_at=None)\
.order_by(cls.created_at.desc())\
.limit(limit)\
.all()
@classmethod
def get_events_for_client(cls, client_id, limit=100):
"""Get audit events for a client.
Args:
client_id: The client ID
limit: Maximum number of events to return
Returns:
List of OIDCAuditLog instances
"""
return cls.query.filter_by(client_id=client_id, deleted_at=None)\
.order_by(cls.created_at.desc())\
.limit(limit)\
.all()
@classmethod
def get_failed_events(cls, client_id=None, user_id=None, start_date=None,
end_date=None, limit=100):
"""Get failed audit events.
Args:
client_id: Optional client ID filter
user_id: Optional user ID filter
start_date: Optional start date filter
end_date: Optional end date filter
limit: Maximum number of events to return
Returns:
List of OIDCAuditLog instances
"""
query = cls.query.filter_by(success=False, deleted_at=None)
if client_id:
query = query.filter_by(client_id=client_id)
if user_id:
query = query.filter_by(user_id=user_id)
if start_date:
query = query.filter(cls.created_at >= start_date)
if end_date:
query = query.filter(cls.created_at <= end_date)
return query.order_by(cls.created_at.desc()).limit(limit).all()
def to_dict(self, exclude=None):
"""Convert to dictionary."""
return super().to_dict(exclude=exclude)
# Add relationship back to User model
from app.models.user import User
User.oidc_audit_logs = db.relationship(
"OIDCAuditLog", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
OIDCClient.audit_logs = db.relationship(
"OIDCAuditLog", back_populates="client", cascade="all, delete-orphan"
)
+120
View File
@@ -0,0 +1,120 @@
"""OIDC Authorization Code model for auth code flow."""
from datetime import datetime, timedelta
from app.extensions import db
from app.models.base import BaseModel
class OIDCAuthCode(BaseModel):
"""OIDC Authorization Code model for authorization code flow.
Authorization codes are single-use, short-lived codes used in the
authorization code grant flow. The code is hashed for security.
"""
__tablename__ = "oidc_authorization_codes"
# Client and User references
client_id = db.Column(
db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True
)
user_id = db.Column(
db.String(36), db.ForeignKey("users.id"), nullable=False, index=True
)
# Authorization code (hashed for security)
code_hash = db.Column(db.String(255), nullable=False)
# Request parameters
redirect_uri = db.Column(db.String(512), nullable=False)
scope = db.Column(db.JSON, nullable=True) # Requested scopes
nonce = db.Column(db.String(255), nullable=True) # For OIDC ID Token validation
code_verifier = db.Column(db.String(255), nullable=True) # For PKCE
# Status tracking
expires_at = db.Column(db.DateTime, nullable=False, index=True)
used_at = db.Column(db.DateTime, nullable=True)
is_used = db.Column(db.Boolean, default=False, nullable=False)
# Request metadata
ip_address = db.Column(db.String(45), nullable=True)
user_agent = db.Column(db.Text, nullable=True)
# Relationships
client = db.relationship("OIDCClient", back_populates="authorization_codes")
user = db.relationship("User", back_populates="oidc_auth_codes")
def __repr__(self):
"""String representation of OIDCAuthCode."""
return f"<OIDCAuthCode client_id={self.client_id} user_id={self.user_id} used={self.is_used}>"
def is_expired(self):
"""Check if the authorization code has expired."""
return datetime.utcnow() > self.expires_at
def is_valid(self):
"""Check if the authorization code is valid for use."""
return not self.is_used and not self.is_expired() and self.deleted_at is None
def mark_as_used(self):
"""Mark the authorization code as used."""
self.is_used = True
self.used_at = datetime.utcnow()
db.session.commit()
@classmethod
def create_code(cls, client_id, user_id, code_hash, redirect_uri, scope=None,
nonce=None, code_verifier=None, ip_address=None, user_agent=None,
lifetime_seconds=600):
"""Create a new authorization code.
Args:
client_id: The OIDC client ID
user_id: The user ID
code_hash: Hashed authorization code
redirect_uri: The redirect URI
scope: Requested scopes
nonce: OIDC nonce
code_verifier: PKCE code verifier
ip_address: Client IP address
user_agent: Client user agent
lifetime_seconds: Code lifetime in seconds (default 10 minutes)
Returns:
OIDCAuthCode instance
"""
code = cls(
client_id=client_id,
user_id=user_id,
code_hash=code_hash,
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_verifier=code_verifier,
expires_at=datetime.utcnow() + timedelta(seconds=lifetime_seconds),
ip_address=ip_address,
user_agent=user_agent,
)
db.session.add(code)
db.session.commit()
return code
def to_dict(self, exclude=None):
"""Convert to dictionary, excluding sensitive fields."""
exclude = exclude or []
# Always exclude code hash
exclude.append("code_hash")
exclude.append("code_verifier")
return super().to_dict(exclude=exclude)
# Add relationship back to User model
from app.models.user import User
User.oidc_auth_codes = db.relationship(
"OIDCAuthCode", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
OIDCClient.authorization_codes = db.relationship(
"OIDCAuthCode", back_populates="client", cascade="all, delete-orphan"
)
+159
View File
@@ -0,0 +1,159 @@
"""OIDC Refresh Token model for token rotation."""
from datetime import datetime
from app.extensions import db
from app.models.base import BaseModel
class OIDCRefreshToken(BaseModel):
"""OIDC Refresh Token model for token refresh and rotation.
Refresh tokens are long-lived credentials used to obtain new access tokens.
They support token rotation for enhanced security.
"""
__tablename__ = "oidc_refresh_tokens"
# Client and User references
client_id = db.Column(
db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True
)
user_id = db.Column(
db.String(36), db.ForeignKey("users.id"), nullable=False, index=True
)
# Token (hashed for security)
token_hash = db.Column(db.String(255), nullable=False, unique=True, index=True)
# Associated access token ID
access_token_id = db.Column(
db.String(36), db.ForeignKey("sessions.id"), nullable=True, index=True
)
# Token scope
scope = db.Column(db.JSON, nullable=True) # Granted scopes
# Timing
expires_at = db.Column(db.DateTime, nullable=False, index=True)
# Revocation tracking
revoked_at = db.Column(db.DateTime, nullable=True)
revoked_reason = db.Column(db.String(255), nullable=True)
# Token rotation metadata
previous_token_hash = db.Column(db.String(255), nullable=True) # For rotation
rotation_count = db.Column(db.Integer, default=0, nullable=False)
# Request metadata
ip_address = db.Column(db.String(45), nullable=True)
user_agent = db.Column(db.Text, nullable=True)
# Relationships
client = db.relationship("OIDCClient", back_populates="refresh_tokens")
user = db.relationship("User", back_populates="oidc_refresh_tokens")
access_token = db.relationship("Session", back_populates="oidc_refresh_token")
def __repr__(self):
"""String representation of OIDCRefreshToken."""
return f"<OIDCRefreshToken client_id={self.client_id} user_id={self.user_id} revoked={self.is_revoked()}>"
def is_expired(self):
"""Check if the refresh token has expired."""
return datetime.utcnow() > self.expires_at
def is_revoked(self):
"""Check if the refresh token has been revoked."""
return self.revoked_at is not None
def is_valid(self):
"""Check if the refresh token is valid for use."""
return not self.is_revoked() and not self.is_expired() and self.deleted_at is None
def revoke(self, reason=None):
"""Revoke the refresh token.
Args:
reason: Optional reason for revocation
"""
self.revoked_at = datetime.utcnow()
self.revoked_reason = reason
db.session.commit()
def rotate(self, new_token_hash):
"""Rotate the refresh token (invalidate old, create new).
Args:
new_token_hash: Hash of the new refresh token
Returns:
self for chaining
"""
# Store reference to old token
self.previous_token_hash = self.token_hash
self.token_hash = new_token_hash
self.rotation_count += 1
# Extend expiration on rotation
from datetime import timedelta
self.expires_at = datetime.utcnow() + timedelta(days=30)
db.session.commit()
return self
@classmethod
def create_token(cls, client_id, user_id, token_hash, scope=None,
access_token_id=None, ip_address=None, user_agent=None,
lifetime_seconds=2592000):
"""Create a new refresh token.
Args:
client_id: The OIDC client ID
user_id: The user ID
token_hash: Hashed refresh token
scope: Granted scopes
access_token_id: Associated access token ID
ip_address: Client IP address
user_agent: Client user agent
lifetime_seconds: Token lifetime in seconds (default 30 days)
Returns:
OIDCRefreshToken instance
"""
from datetime import timedelta
token = cls(
client_id=client_id,
user_id=user_id,
token_hash=token_hash,
scope=scope,
access_token_id=access_token_id,
expires_at=datetime.utcnow() + timedelta(seconds=lifetime_seconds),
ip_address=ip_address,
user_agent=user_agent,
)
db.session.add(token)
db.session.commit()
return token
def to_dict(self, exclude=None):
"""Convert to dictionary, excluding sensitive fields."""
exclude = exclude or []
# Always exclude token hashes
exclude.append("token_hash")
exclude.append("previous_token_hash")
return super().to_dict(exclude=exclude)
# Add relationship back to User model
from app.models.user import User
User.oidc_refresh_tokens = db.relationship(
"OIDCRefreshToken", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
OIDCClient.refresh_tokens = db.relationship(
"OIDCRefreshToken", back_populates="client", cascade="all, delete-orphan"
)
# Add relationship back to Session model
from app.models.session import Session
Session.oidc_refresh_token = db.relationship(
"OIDCRefreshToken", back_populates="access_token", uselist=False
)
+162
View File
@@ -0,0 +1,162 @@
"""OIDC Session model for OIDC session tracking."""
from datetime import datetime
from app.extensions import db
from app.models.base import BaseModel
class OIDCSession(BaseModel):
"""OIDC Session model for tracking OIDC authentication sessions.
This model tracks the state during the OIDC authentication flow,
including PKCE parameters and nonce validation.
"""
__tablename__ = "oidc_sessions"
# User reference
user_id = db.Column(
db.String(36), db.ForeignKey("users.id"), nullable=False, index=True
)
# Client reference
client_id = db.Column(
db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True
)
# State management
state = db.Column(db.String(255), nullable=False, index=True)
nonce = db.Column(db.String(255), nullable=True) # For OIDC ID Token validation
# Authorization request parameters
redirect_uri = db.Column(db.String(512), nullable=False)
scope = db.Column(db.JSON, nullable=True) # Requested scopes
# PKCE parameters
code_challenge = db.Column(db.String(255), nullable=True)
code_challenge_method = db.Column(db.String(10), nullable=True) # "S256" or "plain"
# Timing
expires_at = db.Column(db.DateTime, nullable=False, index=True)
authenticated_at = db.Column(db.DateTime, nullable=True)
# Relationships
user = db.relationship("User", back_populates="oidc_sessions")
client = db.relationship("OIDCClient", back_populates="oidc_sessions")
def __repr__(self):
"""String representation of OIDCSession."""
return f"<OIDCSession user_id={self.user_id} client_id={self.client_id} state={self.state[:8]}...>"
def is_expired(self):
"""Check if the OIDC session has expired."""
return datetime.utcnow() > self.expires_at
def is_authenticated(self):
"""Check if the user has been authenticated in this session."""
return self.authenticated_at is not None
def mark_authenticated(self):
"""Mark the session as authenticated."""
self.authenticated_at = datetime.utcnow()
db.session.commit()
def validate_nonce(self, expected_nonce):
"""Validate the nonce matches the expected value.
Args:
expected_nonce: The expected nonce value
Returns:
bool: True if nonce matches
"""
return self.nonce == expected_nonce
def validate_code_challenge(self, code_verifier):
"""Validate the code verifier against the stored code challenge.
Args:
code_verifier: The PKCE code verifier
Returns:
bool: True if code challenge is valid
"""
if not self.code_challenge:
return False
if self.code_challenge_method == "S256":
import hashlib
import base64
# SHA256 hash of code_verifier
digest = hashlib.sha256(code_verifier.encode()).digest()
# Base64 URL encode without padding
expected = base64.urlsafe_b64encode(digest).decode().rstrip("=")
return self.code_challenge == expected
elif self.code_challenge_method == "plain":
return self.code_challenge == code_verifier
return False
@classmethod
def create_session(cls, user_id, client_id, state, redirect_uri, scope=None,
nonce=None, code_challenge=None, code_challenge_method=None,
lifetime_seconds=600):
"""Create a new OIDC session.
Args:
user_id: The user ID
client_id: The OIDC client ID
state: The state parameter
redirect_uri: The redirect URI
scope: Requested scopes
nonce: OIDC nonce
code_challenge: PKCE code challenge
code_challenge_method: PKCE method ("S256" or "plain")
lifetime_seconds: Session lifetime in seconds
Returns:
OIDCSession instance
"""
from datetime import timedelta
session = cls(
user_id=user_id,
client_id=client_id,
state=state,
redirect_uri=redirect_uri,
scope=scope,
nonce=nonce,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
expires_at=datetime.utcnow() + timedelta(seconds=lifetime_seconds),
)
db.session.add(session)
db.session.commit()
return session
@classmethod
def get_by_state(cls, state):
"""Get a session by state parameter.
Args:
state: The state parameter
Returns:
OIDCSession instance or None
"""
return cls.query.filter_by(state=state, deleted_at=None).first()
def to_dict(self, exclude=None):
"""Convert to dictionary."""
return super().to_dict(exclude=exclude)
# Add relationship back to User model
from app.models.user import User
User.oidc_sessions = db.relationship(
"OIDCSession", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
OIDCClient.oidc_sessions = db.relationship(
"OIDCSession", back_populates="client", cascade="all, delete-orphan"
)
+192
View File
@@ -0,0 +1,192 @@
"""OIDC Token Metadata model for token revocation tracking."""
import uuid
from datetime import datetime
from app.extensions import db
from app.models.base import BaseModel
class OIDCTokenMetadata(BaseModel):
"""OIDC Token Metadata model for tracking issued tokens.
This model stores metadata about issued tokens (access tokens, refresh tokens, ID tokens)
for the purpose of token revocation. The id field matches the JTI (JWT ID) claim.
"""
__tablename__ = "oidc_token_metadata"
# Token identifier (matches JTI in JWT)
id = db.Column(
db.String(36), primary_key=True, default=lambda: str(uuid.uuid4())
)
# Client and User references
client_id = db.Column(
db.String(255), db.ForeignKey("oidc_clients.id"), nullable=False, index=True
)
user_id = db.Column(
db.String(36), db.ForeignKey("users.id"), nullable=False, index=True
)
# Token type
token_type = db.Column(db.String(50), nullable=False) # "access_token", "refresh_token", "id_token"
# Token identifier for revocation lookup
token_jti = db.Column(db.String(255), nullable=False, index=True) # JWT ID claim
# Timing
expires_at = db.Column(db.DateTime, nullable=False, index=True)
# Revocation tracking
revoked_at = db.Column(db.DateTime, nullable=True)
revoked_reason = db.Column(db.String(255), nullable=True)
# Relationships
client = db.relationship("OIDCClient", back_populates="token_metadata")
user = db.relationship("User", back_populates="oidc_token_metadata")
def __repr__(self):
"""String representation of OIDCTokenMetadata."""
return f"<OIDCTokenMetadata jti={self.token_jti[:8]}... type={self.token_type} revoked={self.is_revoked()}>"
def is_expired(self):
"""Check if the token has expired."""
return datetime.utcnow() > self.expires_at
def is_revoked(self):
"""Check if the token has been revoked."""
return self.revoked_at is not None
def is_valid(self):
"""Check if the token is valid (not expired and not revoked)."""
return not self.is_revoked() and not self.is_expired() and self.deleted_at is None
def revoke(self, reason=None):
"""Revoke the token.
Args:
reason: Optional reason for revocation
"""
self.revoked_at = datetime.utcnow()
self.revoked_reason = reason
db.session.commit()
@classmethod
def create_metadata(cls, client_id, user_id, token_type, token_jti,
expires_at, ip_address=None, user_agent=None):
"""Create token metadata for tracking.
Args:
client_id: The OIDC client ID
user_id: The user ID
token_type: Type of token ("access_token", "refresh_token", "id_token")
token_jti: JWT ID claim
expires_at: Token expiration datetime
ip_address: Client IP address
user_agent: Client user agent
Returns:
OIDCTokenMetadata instance
"""
metadata = cls(
id=str(uuid.uuid4()),
client_id=client_id,
user_id=user_id,
token_type=token_type,
token_jti=token_jti,
expires_at=expires_at,
)
db.session.add(metadata)
db.session.commit()
return metadata
@classmethod
def get_by_jti(cls, token_jti):
"""Get token metadata by JWT ID.
Args:
token_jti: The JWT ID
Returns:
OIDCTokenMetadata instance or None
"""
return cls.query.filter_by(token_jti=token_jti, deleted_at=None).first()
@classmethod
def revoke_by_jti(cls, token_jti, reason=None):
"""Revoke a token by its JWT ID.
Args:
token_jti: The JWT ID
reason: Optional revocation reason
Returns:
bool: True if token was found and revoked
"""
metadata = cls.get_by_jti(token_jti)
if metadata:
metadata.revoke(reason)
return True
return False
@classmethod
def revoke_all_for_user(cls, user_id, client_id=None, reason=None):
"""Revoke all tokens for a user.
Args:
user_id: The user ID
client_id: Optional client ID to filter by
reason: Optional revocation reason
Returns:
int: Number of tokens revoked
"""
query = cls.query.filter_by(user_id=user_id, deleted_at=None)
if client_id:
query = query.filter_by(client_id=client_id)
tokens = query.filter(cls.revoked_at == None).all()
count = 0
for token in tokens:
token.revoke(reason)
count += 1
return count
@classmethod
def revoke_all_for_client(cls, client_id, user_id=None, reason=None):
"""Revoke all tokens for a client.
Args:
client_id: The client ID
user_id: Optional user ID to filter by
reason: Optional revocation reason
Returns:
int: Number of tokens revoked
"""
query = cls.query.filter_by(client_id=client_id, deleted_at=None)
if user_id:
query = query.filter_by(user_id=user_id)
tokens = query.filter(cls.revoked_at == None).all()
count = 0
for token in tokens:
token.revoke(reason)
count += 1
return count
def to_dict(self, exclude=None):
"""Convert to dictionary."""
return super().to_dict(exclude=exclude)
# Add relationship back to User model
from app.models.user import User
User.oidc_token_metadata = db.relationship(
"OIDCTokenMetadata", back_populates="user", cascade="all, delete-orphan"
)
# Add relationship back to OIDCClient model
from app.models.oidc_client import OIDCClient
OIDCClient.token_metadata = db.relationship(
"OIDCTokenMetadata", back_populates="client", cascade="all, delete-orphan"
)