Chore: Refractor Models into organized file/folder

This commit is contained in:
2026-03-01 12:40:48 +05:45
parent 58432da1c8
commit 07193a2d2e
35 changed files with 1475 additions and 932 deletions
+17
View File
@@ -0,0 +1,17 @@
"""SSH/CA subpackage — certificate authorities, SSH keys, and certificates."""
from gatehouse_app.models.ssh_ca.ca import CA, KeyType, CertType, CaType, CAPermission
from gatehouse_app.models.ssh_ca.ssh_key import SSHKey
from gatehouse_app.models.ssh_ca.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.ssh_ca.certificate_audit_log import CertificateAuditLog
__all__ = [
"CA",
"KeyType",
"CertType",
"CaType",
"CAPermission",
"SSHKey",
"SSHCertificate",
"CertificateStatus",
"CertificateAuditLog",
]
+212
View File
@@ -0,0 +1,212 @@
"""Certificate Authority (CA) model."""
from enum import Enum
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class KeyType(str, Enum):
"""SSH CA key types."""
ED25519 = "ed25519"
RSA = "rsa"
ECDSA = "ecdsa"
class CertType(str, Enum):
"""SSH certificate types."""
USER = "user"
HOST = "host"
class CaType(str, Enum):
"""CA signing type — whether this CA signs user or host certificates."""
USER = "user"
HOST = "host"
class CA(BaseModel):
"""Certificate Authority (CA) model for SSH certificate signing.
Each organization can have multiple CAs for different purposes
(e.g., production vs. staging). Private keys are encrypted at rest
and should be protected with KMS.
"""
__tablename__ = "cas"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=True, # NULL for the global system-config CA
index=True,
)
# CA identity
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True)
# CA signing type: 'user' signs user certificates, 'host' signs host certs
ca_type = db.Column(
db.Enum(CaType, values_callable=lambda x: [e.value for e in x]),
default=CaType.USER,
nullable=False,
)
# Key type (ED25519, RSA, ECDSA)
key_type = db.Column(
db.Enum(KeyType, values_callable=lambda x: [e.value for e in x]),
default=KeyType.ED25519,
nullable=False,
)
# Private key — PEM-encoded, encrypted at rest by database/KMS
private_key = db.Column(db.Text, nullable=False)
# Public key — PEM format
public_key = db.Column(db.Text, nullable=False)
# SHA256 fingerprint of the public key
fingerprint = db.Column(db.String(255), nullable=False, unique=True)
# CRL (Certificate Revocation List) configuration
crl_enabled = db.Column(db.Boolean, default=True, nullable=False)
crl_endpoint = db.Column(db.String(512), nullable=True)
# Default certificate validity in hours (overridable per request)
default_cert_validity_hours = db.Column(db.Integer, default=1, nullable=False)
# Maximum validity duration allowed
max_cert_validity_hours = db.Column(db.Integer, default=24, nullable=False)
# CA status
is_active = db.Column(db.Boolean, default=True, nullable=False, index=True)
# Key rotation tracking
rotated_at = db.Column(db.DateTime, nullable=True)
rotation_reason = db.Column(db.String(255), nullable=True)
# Relationships
organization = db.relationship("Organization", back_populates="cas")
certificates = db.relationship(
"SSHCertificate",
back_populates="ca",
cascade="all, delete-orphan",
)
permissions = db.relationship(
"CAPermission",
back_populates="ca",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint("organization_id", "name", name="uix_org_ca_name"),
db.Index("idx_ca_org_active", "organization_id", "is_active"),
)
def __repr__(self):
"""String representation of CA."""
return (
f"<CA {self.name} "
f"(org_id={self.organization_id}, type={self.key_type})>"
)
def to_dict(self, exclude=None):
"""Convert CA to dictionary, never exposing the private key."""
exclude = exclude or []
if "private_key" not in exclude:
exclude.append("private_key")
data = super().to_dict(exclude=exclude)
# Add computed fields
data["total_certs"] = len([c for c in self.certificates if c.deleted_at is None])
data["active_certs"] = len(
[c for c in self.certificates if c.deleted_at is None and not c.revoked]
)
data["revoked_certs"] = len(
[c for c in self.certificates if c.deleted_at is None and c.revoked]
)
return data
def get_active_certificates(self) -> list:
"""Get all active (non-revoked) certificates issued by this CA."""
return [
c for c in self.certificates if c.deleted_at is None and not c.revoked
]
def rotate_key(
self,
new_private_key: str,
new_public_key: str,
new_fingerprint: str,
reason: str = None,
) -> None:
"""Rotate the CA's key pair.
This should only be done in carefully controlled circumstances.
All existing certificates remain valid but no new certificates can be
signed with the old key after rotation.
Args:
new_private_key: New PEM-encoded private key
new_public_key: New PEM-encoded public key
new_fingerprint: SHA256 fingerprint of new public key
reason: Optional reason for rotation
"""
self.private_key = new_private_key
self.public_key = new_public_key
self.fingerprint = new_fingerprint
self.rotated_at = datetime.now(timezone.utc) # Bug fix: was datetime.utcnow()
self.rotation_reason = reason
self.save()
class CAPermission(BaseModel):
"""Per-user CA permission model.
Controls which users are allowed to sign certificates against a specific CA.
When a CA has any permission rows, the signing endpoint enforces the list;
CAs with no rows are open to all org members (backwards-compatible default).
Permission values:
sign user may request certificate signing
admin user may sign AND manage the CA (rotate keys, delete, etc.)
"""
__tablename__ = "ca_permissions"
ca_id = db.Column(
db.String(36),
db.ForeignKey("cas.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
permission = db.Column(db.String(50), nullable=False, default="sign")
# Relationships
ca = db.relationship("CA", back_populates="permissions")
user = db.relationship("User", back_populates="ca_permissions")
__table_args__ = (
db.UniqueConstraint("ca_id", "user_id", name="uix_ca_permission"),
)
def __repr__(self):
return (
f"<CAPermission ca_id={self.ca_id} "
f"user_id={self.user_id} permission={self.permission}>"
)
def to_dict(self, exclude=None):
data = super().to_dict(exclude=exclude or [])
data["permission"] = self.permission
return data
@@ -0,0 +1,91 @@
"""Certificate audit log model — tracks SSH certificate lifecycle events."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class CertificateAuditLog(BaseModel):
"""Audit log for SSH certificate lifecycle events.
Tracks all operations on SSH certificates: signing, revocation, validation,
etc. Kept separate from the general AuditLog to provide detailed certificate
operation tracking without polluting the main audit stream.
"""
__tablename__ = "certificate_audit_logs"
# Reference to the certificate
certificate_id = db.Column(
db.String(36),
db.ForeignKey("ssh_certificates.id"),
nullable=False,
index=True,
)
# The user who performed the action (null for system actions)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=True,
index=True,
)
# Action type (e.g., "signed", "revoked", "validated", "requested")
action = db.Column(db.String(50), nullable=False, index=True)
# Request details
ip_address = db.Column(db.String(45), nullable=True)
user_agent = db.Column(db.String(512), nullable=True)
request_id = db.Column(db.String(36), nullable=True)
# Detailed message
message = db.Column(db.Text, nullable=True)
# Additional context
extra_data = db.Column(db.JSON, nullable=True)
# Outcome
success = db.Column(db.Boolean, default=True, nullable=False)
error_message = db.Column(db.Text, nullable=True)
# Relationships
certificate = db.relationship("SSHCertificate", back_populates="audit_logs")
user = db.relationship("User")
__table_args__ = (
db.Index("idx_cert_audit_cert_action", "certificate_id", "action"),
db.Index("idx_cert_audit_user", "user_id", "created_at"),
)
def __repr__(self):
"""String representation of CertificateAuditLog."""
return (
f"<CertificateAuditLog cert_id={self.certificate_id} action={self.action}>"
)
@classmethod
def log(
cls,
certificate_id: str,
action: str,
user_id: str = None,
**kwargs,
) -> "CertificateAuditLog":
"""Create a certificate audit log entry.
Args:
certificate_id: ID of the certificate
action: Action type (e.g., "signed", "revoked")
user_id: ID of the user performing the action (optional)
**kwargs: Additional fields (ip_address, user_agent, message, etc.)
Returns:
CertificateAuditLog instance
"""
log_entry = cls(
certificate_id=certificate_id,
action=action,
user_id=user_id,
**kwargs,
)
log_entry.save()
return log_entry
@@ -0,0 +1,176 @@
"""SSH Certificate model — signed SSH user/host certificates."""
from enum import Enum
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.ssh_ca.ca import CertType
class CertificateStatus(str, Enum):
"""SSH certificate lifecycle status."""
REQUESTED = "requested" # Waiting for signing
ISSUED = "issued" # Signed and valid
REVOKED = "revoked" # Manually revoked
EXPIRED = "expired" # Validity period ended
SUPERSEDED = "superseded" # Replaced by newer certificate
class SSHCertificate(BaseModel):
"""SSH Certificate model representing a signed SSH user/host certificate.
Certificates are issued by a CA and associated with an SSH public key.
They include principals (access levels), validity periods, and standard
OpenSSH certificate metadata.
"""
__tablename__ = "ssh_certificates"
# Certificate relationships
ca_id = db.Column(
db.String(36),
db.ForeignKey("cas.id"),
nullable=False,
index=True,
)
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
ssh_key_id = db.Column(
db.String(36),
db.ForeignKey("ssh_keys.id"),
nullable=False,
index=True,
)
# Certificate content — full signed certificate in OpenSSH format
certificate = db.Column(db.Text, nullable=False)
# Certificate metadata
serial = db.Column(db.String(255), nullable=False, unique=True, index=True)
key_id = db.Column(db.String(255), nullable=False) # Usually user email
cert_type = db.Column(
db.Enum(CertType, values_callable=lambda x: [e.value for e in x]),
default=CertType.USER,
nullable=False,
)
# Principals — JSON list, e.g., ["prod-servers", "dev-servers"]
principals = db.Column(db.JSON, nullable=False, default=list)
# Validity period
valid_after = db.Column(db.DateTime, nullable=False)
valid_before = db.Column(db.DateTime, nullable=False)
# Revocation status
revoked = db.Column(db.Boolean, default=False, nullable=False, index=True)
revoked_at = db.Column(db.DateTime, nullable=True)
revoke_reason = db.Column(db.String(255), nullable=True)
# Status tracking
status = db.Column(
db.Enum(CertificateStatus, values_callable=lambda x: [e.value for e in x]),
default=CertificateStatus.ISSUED,
nullable=False,
index=True,
)
# Request metadata
request_ip = db.Column(db.String(45), nullable=True)
request_user_agent = db.Column(db.String(512), nullable=True)
# Critical options — OpenSSH critical options (JSON)
critical_options = db.Column(db.JSON, nullable=True, default=dict)
# Extensions — OpenSSH extensions (JSON)
extensions = db.Column(db.JSON, nullable=True, default=dict)
# Relationships
ca = db.relationship("CA", back_populates="certificates")
user = db.relationship("User", back_populates="ssh_certificates")
ssh_key = db.relationship(
"SSHKey",
back_populates="certificates",
foreign_keys="SSHCertificate.ssh_key_id",
)
audit_logs = db.relationship(
"CertificateAuditLog",
back_populates="certificate",
cascade="all, delete-orphan",
)
__table_args__ = (
db.Index("idx_cert_user_status", "user_id", "status"),
db.Index("idx_cert_validity", "valid_after", "valid_before"),
db.Index("idx_cert_revoked", "revoked", "revoked_at"),
)
def __repr__(self):
"""String representation of SSHCertificate."""
return f"<SSHCertificate serial={self.serial[:16]}... user_id={self.user_id}>"
def to_dict(self, exclude=None):
"""Convert certificate to dictionary.
The raw ``certificate`` blob is excluded by default (it is large and
callers can request it explicitly by removing it from the exclude list).
"""
exclude = exclude or []
if "certificate" not in exclude:
exclude.append("certificate")
data = super().to_dict(exclude=exclude)
data["is_valid"] = self.is_valid()
data["days_until_expiry"] = self.days_until_expiry()
return data
def _aware(self, dt: datetime) -> datetime:
"""Return a timezone-aware UTC datetime."""
return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt
def is_valid(self) -> bool:
"""Check if certificate is currently valid.
Returns:
True if certificate is issued, not revoked, and within validity period
"""
if self.revoked or self.status == CertificateStatus.REVOKED:
return False
now = datetime.now(timezone.utc)
return self._aware(self.valid_after) <= now <= self._aware(self.valid_before)
def is_expired(self) -> bool:
"""Check if certificate has expired.
Returns:
True if current time is past valid_before
"""
return datetime.now(timezone.utc) > self._aware(self.valid_before)
def days_until_expiry(self) -> int:
"""Get number of days until certificate expires.
Returns:
Number of days remaining (negative if already expired)
"""
delta = self._aware(self.valid_before) - datetime.now(timezone.utc)
return delta.days + (1 if delta.seconds > 0 else 0)
def revoke(self, reason: str = None) -> None:
"""Revoke this certificate.
Args:
reason: Optional reason for revocation
"""
self.revoked = True
self.revoked_at = datetime.now(timezone.utc) # Bug fix: was datetime.utcnow()
self.revoke_reason = reason
self.status = CertificateStatus.REVOKED
self.save()
def mark_expired(self) -> None:
"""Mark certificate as expired when validity period ends."""
self.status = CertificateStatus.EXPIRED
self.save()
+98
View File
@@ -0,0 +1,98 @@
"""SSH Key model — user SSH public keys registered for certificate signing."""
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class SSHKey(BaseModel):
"""SSH Key model representing a user's SSH public key.
Users register SSH public keys for certificate signing. Keys must be
verified (owner proved possession) before they can be used.
"""
__tablename__ = "ssh_keys"
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
# SSH key payload in OpenSSH format (e.g., "ssh-ed25519 AAAAB3Nz...")
payload = db.Column(db.Text, nullable=False, unique=True)
# SHA256 fingerprint for quick comparison and deduplication
fingerprint = db.Column(db.String(255), nullable=False, unique=True, index=True)
# Optional human-readable description (e.g., "My laptop key")
description = db.Column(db.String(255), nullable=True)
# Verification status
verified = db.Column(db.Boolean, default=False, nullable=False, index=True)
verified_at = db.Column(db.DateTime, nullable=True)
# Verification challenge — shown to user once, cleared after verification
verify_text = db.Column(db.String(255), nullable=True)
verify_text_created_at = db.Column(db.DateTime, nullable=True)
# Key metadata extracted from the key
key_type = db.Column(db.String(50), nullable=True) # ssh-rsa, ssh-ed25519, etc.
key_bits = db.Column(db.Integer, nullable=True) # key length
key_comment = db.Column(db.String(255), nullable=True)
# Relationships
user = db.relationship("User", back_populates="ssh_keys")
certificates = db.relationship(
"SSHCertificate",
back_populates="ssh_key",
cascade="all, delete-orphan",
foreign_keys="SSHCertificate.ssh_key_id",
)
__table_args__ = (
db.Index("idx_ssh_key_user_verified", "user_id", "verified"),
)
def __repr__(self):
"""String representation of SSHKey."""
return f"<SSHKey {self.fingerprint[:16]}... user_id={self.user_id}>"
def to_dict(self, exclude=None):
"""Convert SSH key to dictionary.
``payload`` and ``verify_text`` are never exposed through the API.
"""
exclude = exclude or []
for field in ("payload", "verify_text"):
if field not in exclude:
exclude.append(field)
data = super().to_dict(exclude=exclude)
data["cert_count"] = len([c for c in self.certificates if c.deleted_at is None])
return data
def mark_verified(self) -> None:
"""Mark this SSH key as verified and clear the challenge."""
self.verified = True
self.verified_at = datetime.now(timezone.utc) # Bug fix: was datetime.utcnow()
self.verify_text = None
self.save()
def needs_verification_refresh(self, max_age_hours: int = 24) -> bool:
"""Check if verification challenge needs to be refreshed.
Args:
max_age_hours: Maximum age of verification challenge in hours
Returns:
True if verification challenge is stale or missing
"""
if not self.verify_text_created_at:
return True
age = datetime.now(timezone.utc) - self.verify_text_created_at.replace(
tzinfo=timezone.utc
) if self.verify_text_created_at.tzinfo is None else (
datetime.now(timezone.utc) - self.verify_text_created_at
)
return age.total_seconds() > (max_age_hours * 3600)