Feat: Added CA-merged with Securid-Principals, Depart, Client-CLI

This commit is contained in:
2026-02-27 21:59:01 +05:45
parent 92fd57447d
commit b2212ab4d6
29 changed files with 3718 additions and 53 deletions
+17
View File
@@ -29,6 +29,13 @@ from gatehouse_app.models.principal import (
Principal,
PrincipalMembership,
)
from gatehouse_app.models.ssh_key import SSHKey
from gatehouse_app.models.ca import CA, KeyType, CertType
from gatehouse_app.models.ssh_certificate import SSHCertificate, CertificateStatus
from gatehouse_app.models.certificate_audit_log import CertificateAuditLog
from gatehouse_app.models.password_reset_token import PasswordResetToken
from gatehouse_app.models.email_verification_token import EmailVerificationToken
from gatehouse_app.models.org_invite_token import OrgInviteToken
__all__ = [
"BaseModel",
@@ -55,4 +62,14 @@ __all__ = [
"DepartmentPrincipal",
"Principal",
"PrincipalMembership",
"SSHKey",
"CA",
"KeyType",
"CertType",
"SSHCertificate",
"CertificateStatus",
"CertificateAuditLog",
"PasswordResetToken",
"EmailVerificationToken",
"OrgInviteToken",
]
+155
View File
@@ -0,0 +1,155 @@
"""Certificate Authority (CA) model."""
from enum import Enum
from datetime import datetime
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 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 name and description
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True)
# 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 (encrypted at rest by database/KMS)
# Format: PEM-encoded private key
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
# Can be overridden per certificate 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",
)
__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} (org_id={self.organization_id}, type={self.key_type})>"
def to_dict(self, exclude=None):
"""Convert CA to dictionary."""
exclude = exclude or []
# Never expose private key in API responses
exclude.extend(["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):
"""Get all active (non-revoked) certificates issued by this CA.
Returns:
List of non-revoked SSHCertificate objects
"""
return [
c for c in self.certificates
if c.deleted_at is None and not c.revoked
]
def rotate_key(self, new_private_key, new_public_key, new_fingerprint, reason=None):
"""Rotate the CA's key pair.
This should only be done in carefully controlled circumstances.
All existing certificates remain valid but no new certs can be
signed with the old key.
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.utcnow()
self.rotation_reason = reason
self.save()
@@ -0,0 +1,83 @@
"""Certificate audit log model."""
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. This is separate from the general AuditLog to
provide detailed certificate operation tracking.
"""
__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 (can be 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)
# Success/failure
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, action, user_id=None, **kwargs):
"""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
+3
View File
@@ -40,6 +40,9 @@ class Organization(BaseModel):
principals = db.relationship(
"Principal", back_populates="organization", cascade="all, delete-orphan"
)
cas = db.relationship(
"CA", back_populates="organization", cascade="all, delete-orphan"
)
def __repr__(self):
"""String representation of Organization."""
+175
View File
@@ -0,0 +1,175 @@
"""SSH Certificate model."""
from enum import Enum
from datetime import datetime
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.models.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 cert
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 other
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 (JSON) - OpenSSH critical options
# See: https://man.openbsd.org/ssh-cert
critical_options = db.Column(db.JSON, nullable=True, default=dict)
# Extensions (JSON) - OpenSSH extensions
# Common ones: permit-X11-forwarding, permit-agent-forwarding, permit-pty, etc.
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."""
exclude = exclude or []
# Optionally exclude the certificate content (it's large)
if "certificate" not in exclude:
exclude.append("certificate")
data = super().to_dict(exclude=exclude)
# Add computed fields
data["is_valid"] = self.is_valid()
data["days_until_expiry"] = self.days_until_expiry()
return data
def is_valid(self):
"""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.utcnow()
return self.valid_after <= now <= self.valid_before
def is_expired(self):
"""Check if certificate has expired.
Returns:
True if current time is past valid_before
"""
return datetime.utcnow() > self.valid_before
def days_until_expiry(self):
"""Get number of days until certificate expires.
Returns:
Number of days remaining (negative if already expired)
"""
delta = self.valid_before - datetime.utcnow()
return delta.days + (1 if delta.seconds > 0 else 0)
def revoke(self, reason=None):
"""Revoke this certificate.
Args:
reason: Optional reason for revocation
"""
self.revoked = True
self.revoked_at = datetime.utcnow()
self.revoke_reason = reason
self.status = CertificateStatus.REVOKED
self.save()
def mark_expired(self):
"""Mark certificate as expired when validity period ends."""
self.status = CertificateStatus.EXPIRED
self.save()
+96
View File
@@ -0,0 +1,96 @@
"""SSH Key model."""
from datetime import datetime
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.
This model stores SSH public keys that users register for certificate signing.
Users must verify ownership of the key before it can be used for signing certificates.
"""
__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-rsa AAAAB3Nz...")
payload = db.Column(db.Text, nullable=False, unique=True)
# SHA256 fingerprint for quick comparison
fingerprint = db.Column(db.String(255), nullable=False, unique=True, index=True)
# Optional description for the key (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
verify_text = db.Column(db.String(255), nullable=True)
verify_text_created_at = db.Column(db.DateTime, nullable=True)
# Key type extracted from the key (ssh-rsa, ssh-ed25519, etc.)
key_type = db.Column(db.String(50), nullable=True)
# Key bits/length
key_bits = db.Column(db.Integer, nullable=True)
# Comment from the key (usually email or key name)
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."""
exclude = exclude or []
exclude.extend(["payload", "verify_text"]) # Never expose these in API
data = super().to_dict(exclude=exclude)
# Add computed fields
data["cert_count"] = len([c for c in self.certificates if c.deleted_at is None])
return data
def mark_verified(self):
"""Mark this SSH key as verified."""
self.verified = True
self.verified_at = datetime.utcnow()
self.save()
def needs_verification_refresh(self, max_age_hours=24):
"""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
"""
if not self.verify_text_created_at:
return True
age = datetime.utcnow() - self.verify_text_created_at
return age.total_seconds() > (max_age_hours * 3600)
+12
View File
@@ -55,6 +55,18 @@ class User(BaseModel):
cascade="all, delete-orphan",
foreign_keys="PrincipalMembership.user_id",
)
ssh_keys = db.relationship(
"SSHKey",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="SSHKey.user_id",
)
ssh_certificates = db.relationship(
"SSHCertificate",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="SSHCertificate.user_id",
)
def __repr__(self):
"""String representation of User."""