Add superadmin routes to API

This commit is contained in:
2026-04-21 17:11:03 +09:30
parent aaec6af6ad
commit 1778dd85d5
33 changed files with 4831 additions and 31 deletions
+13
View File
@@ -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",
]
+5
View File
@@ -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"]
+61
View File
@@ -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)
+40
View File
@@ -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.