Feat: Added CA-merged with Securid-Principals, Depart, Client-CLI
This commit is contained in:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user