Add superadmin routes to API
This commit is contained in:
@@ -113,6 +113,14 @@ from gatehouse_app.models.zerotier import ( # noqa: F401
|
||||
ZeroTierMembership,
|
||||
KillSwitchEvent,
|
||||
)
|
||||
|
||||
# ── Superadmin ─────────────────────────────────────────────────────────────────
|
||||
from gatehouse_app.models.superadmin import ( # noqa: F401
|
||||
Superadmin,
|
||||
SuperadminSession,
|
||||
SuperadminSessionStatus,
|
||||
)
|
||||
from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog # noqa: F401
|
||||
from gatehouse_app.models.security.user_security_policy import ( # noqa: F401
|
||||
UserSecurityPolicy,
|
||||
)
|
||||
@@ -175,4 +183,9 @@ __all__ = [
|
||||
"ActivationSession",
|
||||
"ZeroTierMembership",
|
||||
"KillSwitchEvent",
|
||||
# Superadmin
|
||||
"Superadmin",
|
||||
"SuperadminSession",
|
||||
"SuperadminSessionStatus",
|
||||
"SuperadminAuditLog",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Billing models package."""
|
||||
from gatehouse_app.models.billing.plan import Plan
|
||||
from gatehouse_app.models.billing.subscription import Subscription, SubscriptionStatus, BillingCycle
|
||||
|
||||
__all__ = ["Plan", "Subscription", "SubscriptionStatus", "BillingCycle"]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Plan model for subscription tiers."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, Text
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Plan(BaseModel):
|
||||
"""Subscription plan definition.
|
||||
|
||||
Represents different pricing tiers that organizations can subscribe to.
|
||||
"""
|
||||
__tablename__ = "plans"
|
||||
|
||||
name = Column(String(100), nullable=False)
|
||||
slug = Column(String(50), unique=True, nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
|
||||
# Pricing in cents
|
||||
price_monthly = Column(Integer, nullable=False, default=0) # Price in cents
|
||||
price_yearly = Column(Integer, nullable=False, default=0) # Price in cents
|
||||
|
||||
# User limits
|
||||
included_users = Column(Integer, nullable=False, default=0) # 0 = unlimited
|
||||
|
||||
# Overage pricing (cents per user over limit)
|
||||
overage_rate_per_user = Column(Integer, nullable=False, default=0)
|
||||
|
||||
# Feature flags (JSON)
|
||||
features = Column(Text, nullable=True) # JSON string
|
||||
|
||||
# Stripe integration
|
||||
stripe_price_id_monthly = Column(String(100), nullable=True)
|
||||
stripe_price_id_yearly = Column(String(100), nullable=True)
|
||||
|
||||
# Active/inactive
|
||||
is_active = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Plan {self.slug}: ${self.price_monthly / 100}/mo>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert plan to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"slug": self.slug,
|
||||
"description": self.description,
|
||||
"price_monthly": self.price_monthly,
|
||||
"price_yearly": self.price_yearly,
|
||||
"included_users": self.included_users,
|
||||
"overage_rate_per_user": self.overage_rate_per_user,
|
||||
"features": self.features,
|
||||
"stripe_price_id_monthly": self.stripe_price_id_monthly,
|
||||
"stripe_price_id_yearly": self.stripe_price_id_yearly,
|
||||
"is_active": self.is_active,
|
||||
"created_at": self.created_at.isoformat() + "Z" if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() + "Z" if self.updated_at else None,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Subscription model for organization billing."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy import Column, String, Integer, Boolean, DateTime, ForeignKey, Enum
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
import enum
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SubscriptionStatus(enum.Enum):
|
||||
"""Subscription status values."""
|
||||
TRIAL = "trial"
|
||||
ACTIVE = "active"
|
||||
PAST_DUE = "past_due"
|
||||
CANCELLED = "cancelled"
|
||||
SUSPENDED = "suspended"
|
||||
|
||||
|
||||
class BillingCycle(enum.Enum):
|
||||
"""Billing cycle values."""
|
||||
MONTHLY = "monthly"
|
||||
YEARLY = "yearly"
|
||||
|
||||
|
||||
class Subscription(BaseModel):
|
||||
"""Organization subscription record.
|
||||
|
||||
Links an organization to a plan and tracks billing state.
|
||||
"""
|
||||
__tablename__ = "subscriptions"
|
||||
|
||||
# Organization relation
|
||||
organization_id = Column(
|
||||
String(36),
|
||||
ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||
unique=True,
|
||||
nullable=False
|
||||
)
|
||||
|
||||
# Plan relation
|
||||
plan_id = Column(
|
||||
String(36),
|
||||
ForeignKey("plans.id", ondelete="SET NULL"),
|
||||
nullable=True
|
||||
)
|
||||
|
||||
# Status
|
||||
status = Column(
|
||||
Enum(SubscriptionStatus, name="subscription_status"),
|
||||
nullable=False,
|
||||
default=SubscriptionStatus.TRIAL
|
||||
)
|
||||
|
||||
# Billing
|
||||
billing_cycle = Column(
|
||||
Enum(BillingCycle, name="billing_cycle"),
|
||||
nullable=False,
|
||||
default=BillingCycle.MONTHLY
|
||||
)
|
||||
|
||||
# Period dates
|
||||
current_period_start = Column(DateTime, nullable=True)
|
||||
current_period_end = Column(DateTime, nullable=True)
|
||||
|
||||
# Trial
|
||||
trial_ends_at = Column(DateTime, nullable=True)
|
||||
|
||||
# Stripe
|
||||
stripe_subscription_id = Column(String(100), nullable=True)
|
||||
|
||||
# Overage
|
||||
overage_enabled = Column(Boolean, nullable=False, default=True)
|
||||
|
||||
# Cancellation
|
||||
cancelled_at = Column(DateTime, nullable=True)
|
||||
cancel_at_period_end = Column(Boolean, nullable=False, default=False)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Subscription org={self.organization_id} status={self.status.value}>"
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert subscription to dictionary."""
|
||||
return {
|
||||
"id": self.id,
|
||||
"organization_id": self.organization_id,
|
||||
"plan_id": self.plan_id,
|
||||
"status": self.status.value if self.status else None,
|
||||
"billing_cycle": self.billing_cycle.value if self.billing_cycle else None,
|
||||
"current_period_start": self.current_period_start.isoformat() + "Z" if self.current_period_start else None,
|
||||
"current_period_end": self.current_period_end.isoformat() + "Z" if self.current_period_end else None,
|
||||
"trial_ends_at": self.trial_ends_at.isoformat() + "Z" if self.trial_ends_at else None,
|
||||
"stripe_subscription_id": self.stripe_subscription_id,
|
||||
"overage_enabled": self.overage_enabled,
|
||||
"cancelled_at": self.cancelled_at.isoformat() + "Z" if self.cancelled_at else None,
|
||||
"cancel_at_period_end": self.cancel_at_period_end,
|
||||
"created_at": self.created_at.isoformat() + "Z" if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() + "Z" if self.updated_at else None,
|
||||
}
|
||||
@@ -78,3 +78,43 @@ class Organization(BaseModel):
|
||||
).first()
|
||||
is not None
|
||||
)
|
||||
def get_active_members(self):
|
||||
"""Get active (non-deleted) organization members.
|
||||
|
||||
Returns:
|
||||
List of OrganizationMember instances where deleted_at is None.
|
||||
"""
|
||||
return [m for m in self.members if m.deleted_at is None]
|
||||
|
||||
def get_active_departments(self):
|
||||
"""Get active (non-deleted) departments.
|
||||
|
||||
Returns:
|
||||
List of Department instances where deleted_at is None.
|
||||
"""
|
||||
return [d for d in self.departments if d.deleted_at is None]
|
||||
|
||||
def get_active_principals(self):
|
||||
"""Get active (non-deleted) principals.
|
||||
|
||||
Returns:
|
||||
List of Principal instances where deleted_at is None.
|
||||
"""
|
||||
return [p for p in self.principals if p.deleted_at is None]
|
||||
|
||||
def get_active_cas(self):
|
||||
"""Get active (non-deleted) certificate authorities.
|
||||
|
||||
Returns:
|
||||
List of CA instances where deleted_at is None.
|
||||
"""
|
||||
return [ca for ca in self.cas if ca.deleted_at is None]
|
||||
|
||||
def get_active_api_keys(self):
|
||||
"""Get active (non-deleted) API keys.
|
||||
|
||||
Returns:
|
||||
List of OrganizationApiKey instances where deleted_at is None.
|
||||
"""
|
||||
return [k for k in self.api_keys if k.deleted_at is None]
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Superadmin models."""
|
||||
from gatehouse_app.models.superadmin.superadmin import Superadmin
|
||||
from gatehouse_app.models.superadmin.superadmin_session import SuperadminSession, SuperadminSessionStatus
|
||||
|
||||
__all__ = ["Superadmin", "SuperadminSession", "SuperadminSessionStatus"]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Superadmin model."""
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Superadmin(BaseModel):
|
||||
"""Superadmin model for SaaS platform operators.
|
||||
|
||||
Completely separate from User model - has its own email/password auth.
|
||||
"""
|
||||
|
||||
__tablename__ = "superadmins"
|
||||
|
||||
email = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
full_name = db.Column(db.String(255), nullable=True)
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationship to sessions
|
||||
sessions = db.relationship(
|
||||
"SuperadminSession",
|
||||
back_populates="superadmin",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
# Relationship to audit logs
|
||||
audit_logs = db.relationship(
|
||||
"SuperadminAuditLog",
|
||||
back_populates="superadmin",
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Superadmin {self.email}>"
|
||||
|
||||
def has_password_auth(self):
|
||||
"""Check if superadmin has password authentication."""
|
||||
return bool(self.password_hash)
|
||||
|
||||
def has_totp_enabled(self):
|
||||
"""Check if superadmin has TOTP enabled."""
|
||||
# TODO: Implement TOTP for superadmin if needed
|
||||
return False
|
||||
|
||||
def to_dict(self, exclude=None):
|
||||
"""Convert to dictionary, excluding sensitive fields."""
|
||||
exclude = exclude or []
|
||||
exclude.append("password_hash")
|
||||
return super().to_dict(exclude=exclude)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Superadmin session model."""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuperadminSessionStatus:
|
||||
"""Session status constants."""
|
||||
ACTIVE = "active"
|
||||
REVOKED = "revoked"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class SuperadminSession(BaseModel):
|
||||
"""Session model for superadmin authentication."""
|
||||
|
||||
__tablename__ = "superadmin_sessions"
|
||||
|
||||
superadmin_id = db.Column(
|
||||
db.String(36),
|
||||
db.ForeignKey("superadmins.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
token = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
last_activity_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.Text, nullable=True)
|
||||
revoked_at = db.Column(db.DateTime, nullable=True)
|
||||
revoked_reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Relationship
|
||||
superadmin = db.relationship("Superadmin", back_populates="sessions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SuperadminSession superadmin_id={self.superadmin_id}>"
|
||||
|
||||
def is_active(self):
|
||||
"""Check if session is currently active."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = self.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
return (
|
||||
self.deleted_at is None
|
||||
and self.revoked_at is None
|
||||
and expires_at > now
|
||||
)
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if session has expired."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = self.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
return now > expires_at
|
||||
|
||||
def revoke(self, reason: str = None):
|
||||
"""Revoke the session."""
|
||||
self.revoked_at = datetime.now(timezone.utc)
|
||||
if reason:
|
||||
self.revoked_reason = reason
|
||||
from gatehouse_app import db
|
||||
db.session.commit()
|
||||
|
||||
def to_dict(self, exclude=None):
|
||||
"""Convert to dictionary, excluding sensitive fields."""
|
||||
exclude = exclude or []
|
||||
exclude.append("token")
|
||||
return super().to_dict(exclude=exclude)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Superadmin audit log model."""
|
||||
import logging
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuperadminAuditLog(BaseModel):
|
||||
"""Audit log for superadmin actions.
|
||||
|
||||
Records every action performed by superadmins for security and compliance.
|
||||
"""
|
||||
|
||||
__tablename__ = "superadmin_audit_logs"
|
||||
|
||||
superadmin_id = db.Column(
|
||||
db.String(36),
|
||||
db.ForeignKey("superadmins.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
action = db.Column(db.String(100), nullable=False, index=True)
|
||||
resource_type = db.Column(db.String(50), nullable=False, index=True)
|
||||
resource_id = db.Column(db.String(36), nullable=True, index=True)
|
||||
org_id = db.Column(db.String(36), nullable=True, index=True)
|
||||
user_id = db.Column(db.String(36), nullable=True, index=True)
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.Text, nullable=True)
|
||||
request_id = db.Column(db.String(100), nullable=True)
|
||||
extra_data = db.Column(db.JSON, nullable=True)
|
||||
success = db.Column(db.Boolean, default=True, nullable=False)
|
||||
error_message = db.Column(db.String(500), nullable=True)
|
||||
|
||||
# Relationship
|
||||
superadmin = db.relationship("Superadmin", back_populates="audit_logs")
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
f"<SuperadminAuditLog superadmin={self.superadmin_id} "
|
||||
f"action={self.action} resource={self.resource_type}/{self.resource_id}>"
|
||||
)
|
||||
|
||||
def to_dict(self, exclude=None):
|
||||
"""Convert to dictionary."""
|
||||
exclude = exclude or []
|
||||
return super().to_dict(exclude=exclude)
|
||||
@@ -134,6 +134,46 @@ class User(BaseModel):
|
||||
def get_organizations(self):
|
||||
"""Get all active organizations the user is a member of."""
|
||||
return [membership.organization for membership in self.get_active_memberships()]
|
||||
def get_active_ssh_keys(self):
|
||||
"""Get active (non-deleted) SSH keys.
|
||||
|
||||
Returns:
|
||||
List of SSHKey instances where deleted_at is None.
|
||||
"""
|
||||
return [k for k in self.ssh_keys if k.deleted_at is None]
|
||||
|
||||
def get_active_auth_methods(self):
|
||||
"""Get active (non-deleted) authentication methods.
|
||||
|
||||
Returns:
|
||||
List of AuthenticationMethod instances where deleted_at is None.
|
||||
"""
|
||||
return [m for m in self.authentication_methods if m.deleted_at is None]
|
||||
|
||||
def get_active_department_memberships(self):
|
||||
"""Get active (non-deleted) department memberships.
|
||||
|
||||
Returns:
|
||||
List of DepartmentMembership instances where deleted_at is None.
|
||||
"""
|
||||
return [m for m in self.department_memberships if m.deleted_at is None]
|
||||
|
||||
def get_active_principal_memberships(self):
|
||||
"""Get active (non-deleted) principal memberships.
|
||||
|
||||
Returns:
|
||||
List of PrincipalMembership instances where deleted_at is None.
|
||||
"""
|
||||
return [m for m in self.principal_memberships if m.deleted_at is None]
|
||||
|
||||
def get_active_ca_permissions(self):
|
||||
"""Get active (non-deleted) CA permissions.
|
||||
|
||||
Returns:
|
||||
List of CAPermission instances where deleted_at is None.
|
||||
"""
|
||||
return [p for p in self.ca_permissions if p.deleted_at is None]
|
||||
|
||||
|
||||
def has_totp_enabled(self) -> bool:
|
||||
"""Check if user has TOTP enabled and verified.
|
||||
|
||||
Reference in New Issue
Block a user