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
@@ -0,0 +1,27 @@
"""Organization subpackage."""
from gatehouse_app.models.organization.organization import Organization
from gatehouse_app.models.organization.organization_member import OrganizationMember
from gatehouse_app.models.organization.department import (
Department,
DepartmentMembership,
DepartmentPrincipal,
)
from gatehouse_app.models.organization.department_cert_policy import (
DepartmentCertPolicy,
STANDARD_EXTENSIONS,
)
from gatehouse_app.models.organization.principal import Principal, PrincipalMembership
from gatehouse_app.models.organization.org_invite_token import OrgInviteToken
__all__ = [
"Organization",
"OrganizationMember",
"Department",
"DepartmentMembership",
"DepartmentPrincipal",
"DepartmentCertPolicy",
"STANDARD_EXTENSIONS",
"Principal",
"PrincipalMembership",
"OrgInviteToken",
]
@@ -0,0 +1,196 @@
"""Department, DepartmentMembership, and DepartmentPrincipal models."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class Department(BaseModel):
"""Department model representing an organizational unit for SSH access control.
Departments are used to group users and assign SSH principals (access levels)
to them. A user can be a member of multiple departments, and each department
can have multiple principals assigned.
Example:
- Department: "Engineering"
- Members: user1@example.com, user2@example.com
- Principals: "eng-prod", "eng-staging"
- Users get access based on their principal assignments
"""
__tablename__ = "departments"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
name = db.Column(db.String(255), nullable=False, index=True)
description = db.Column(db.Text, nullable=True)
# Relationships
organization = db.relationship("Organization", back_populates="departments")
memberships = db.relationship(
"DepartmentMembership",
back_populates="department",
cascade="all, delete-orphan",
)
principal_links = db.relationship(
"DepartmentPrincipal",
back_populates="department",
cascade="all, delete-orphan",
)
cert_policy = db.relationship(
"DepartmentCertPolicy",
back_populates="department",
uselist=False,
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint("organization_id", "name", name="uix_org_dept_name"),
)
def __repr__(self):
"""String representation of Department."""
return f"<Department {self.name} (org_id={self.organization_id})>"
def to_dict(self, exclude=None):
"""Convert department to dictionary."""
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["member_count"] = len([m for m in self.memberships if m.deleted_at is None])
data["principal_count"] = len([p for p in self.principal_links if p.deleted_at is None])
return data
def get_members(self, active_only: bool = True):
"""Get all members of this department.
Args:
active_only: If True, exclude soft-deleted members
Returns:
List of DepartmentMembership objects
"""
if active_only:
return [m for m in self.memberships if m.deleted_at is None]
return list(self.memberships)
def get_principals(self, active_only: bool = True):
"""Get all principals assigned to this department.
Args:
active_only: If True, exclude soft-deleted principals
Returns:
List of Principal objects via DepartmentPrincipal
"""
if active_only:
return [
p.principal
for p in self.principal_links
if p.deleted_at is None and p.principal.deleted_at is None
]
return [p.principal for p in self.principal_links]
def is_member(self, user_id: str) -> bool:
"""Check if a user is a member of this department.
Args:
user_id: ID of the user to check
Returns:
True if user is an active member, False otherwise
"""
return (
DepartmentMembership.query.filter_by(
user_id=user_id,
department_id=self.id,
deleted_at=None,
).first()
is not None
)
def get_member_count(self) -> int:
"""Get the count of active members in this department."""
return len(self.get_members(active_only=True))
class DepartmentMembership(BaseModel):
"""Department membership model representing user membership in a department.
When a user is added to a department, they become eligible for SSH principals
assigned to that department.
"""
__tablename__ = "department_memberships"
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
department_id = db.Column(
db.String(36),
db.ForeignKey("departments.id"),
nullable=False,
index=True,
)
# Relationships
user = db.relationship("User", back_populates="department_memberships")
department = db.relationship("Department", back_populates="memberships")
__table_args__ = (
db.UniqueConstraint("user_id", "department_id", name="uix_user_dept"),
)
def __repr__(self):
"""String representation of DepartmentMembership."""
return (
f"<DepartmentMembership user_id={self.user_id} dept_id={self.department_id}>"
)
class DepartmentPrincipal(BaseModel):
"""Department principal assignment model.
Represents the assignment of principals to departments. All members of a
department get access to its assigned principals (transitively).
Example:
- Department: "Engineering"
- Principal: "eng-prod-servers"
- All engineering department members can SSH as "eng-prod-servers"
"""
__tablename__ = "department_principals"
department_id = db.Column(
db.String(36),
db.ForeignKey("departments.id"),
nullable=False,
index=True,
)
principal_id = db.Column(
db.String(36),
db.ForeignKey("principals.id"),
nullable=False,
index=True,
)
# Relationships
department = db.relationship("Department", back_populates="principal_links")
principal = db.relationship("Principal", back_populates="department_links")
__table_args__ = (
db.UniqueConstraint("department_id", "principal_id", name="uix_dept_principal"),
)
def __repr__(self):
"""String representation of DepartmentPrincipal."""
return (
f"<DepartmentPrincipal dept_id={self.department_id} "
f"principal_id={self.principal_id}>"
)
@@ -0,0 +1,76 @@
"""DepartmentCertPolicy — per-department SSH certificate issuance rules."""
from datetime import datetime, timezone
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
# Standard SSH certificate extensions
STANDARD_EXTENSIONS = [
"permit-X11-forwarding",
"permit-agent-forwarding",
"permit-pty",
"permit-port-forwarding",
"permit-user-rc",
]
class DepartmentCertPolicy(BaseModel):
"""SSH certificate policy for a department.
Controls:
- Whether members may choose their own expiry date (up to ``max_expiry_hours``)
- Default expiry hours when the user doesn't (or can't) pick
- Maximum expiry hours (hard ceiling, even for admins signing on behalf)
- Which SSH certificate extensions are granted to members of this department
- Any custom extensions the admin wants to add beyond the standard five
Inherits ``id``, ``created_at``, ``updated_at``, and ``deleted_at`` from
:class:`BaseModel` so soft-delete and the standard timestamp behaviour are
consistent with every other model in the application.
"""
__tablename__ = "department_cert_policies"
department_id = db.Column(
db.String(36),
db.ForeignKey("departments.id"),
nullable=False,
unique=True,
index=True,
)
# Expiry control
allow_user_expiry = db.Column(db.Boolean, nullable=False, default=False)
default_expiry_hours = db.Column(db.Integer, nullable=False, default=1)
max_expiry_hours = db.Column(db.Integer, nullable=False, default=24)
# Extensions — list of extension name strings
allowed_extensions = db.Column(
db.JSON,
nullable=False,
default=lambda: list(STANDARD_EXTENSIONS),
)
# Admin-defined extras beyond the standard five
custom_extensions = db.Column(db.JSON, nullable=False, default=list)
# Relationship back to department
department = db.relationship("Department", back_populates="cert_policy", uselist=False)
def __repr__(self):
return (
f"<DepartmentCertPolicy dept={self.department_id} "
f"allow_user_expiry={self.allow_user_expiry}>"
)
def all_extensions(self) -> list:
"""Return the full list of enabled extensions (allowed + custom)."""
return list((self.allowed_extensions or []) + (self.custom_extensions or []))
def to_dict(self, exclude=None):
"""Convert to dictionary."""
exclude = exclude or []
data = super().to_dict(exclude=exclude)
# Augment with computed / convenience fields not in the base columns
data["all_extensions"] = self.all_extensions()
data["standard_extensions"] = STANDARD_EXTENSIONS
return data
@@ -0,0 +1,77 @@
"""Organization invite token model."""
import secrets
from datetime import datetime, timezone, timedelta
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class OrgInviteToken(BaseModel):
"""Token-based invitation to join an organization."""
__tablename__ = "org_invite_tokens"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
invited_by_id = db.Column(
db.String(36),
db.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
email = db.Column(db.String(255), nullable=False, index=True)
role = db.Column(db.String(64), nullable=False, default="member")
token = db.Column(db.String(128), unique=True, nullable=False, index=True)
expires_at = db.Column(db.DateTime, nullable=False)
accepted_at = db.Column(db.DateTime, nullable=True)
organization = db.relationship(
"Organization",
backref=db.backref("invite_tokens", cascade="all, delete-orphan"),
)
invited_by = db.relationship("User", foreign_keys=[invited_by_id])
@classmethod
def generate(
cls,
organization_id: str,
email: str,
role: str = "member",
invited_by_id: str = None,
ttl_days: int = 7,
) -> "OrgInviteToken":
"""Create a new invite token for an organization."""
token_value = secrets.token_urlsafe(48)
instance = cls(
organization_id=organization_id,
email=email.lower(),
role=role,
invited_by_id=invited_by_id,
token=token_value,
expires_at=datetime.now(timezone.utc) + timedelta(days=ttl_days),
)
db.session.add(instance)
db.session.commit()
return instance
@property
def is_valid(self) -> bool:
"""Return True if the token is unused and not expired."""
if self.accepted_at is not None:
return False
now = datetime.now(timezone.utc)
expires = self.expires_at
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
return now < expires
def accept(self) -> None:
"""Mark the invite as accepted."""
self.accepted_at = datetime.now(timezone.utc)
db.session.commit()
def __repr__(self) -> str:
return f"<OrgInviteToken org={self.organization_id} email={self.email}>"
@@ -0,0 +1,73 @@
"""Organization model."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class Organization(BaseModel):
"""Organization model representing a tenant/workspace."""
__tablename__ = "organizations"
name = db.Column(db.String(255), nullable=False)
slug = db.Column(db.String(255), unique=True, nullable=False, index=True)
description = db.Column(db.Text, nullable=True)
logo_url = db.Column(db.String(512), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Settings (stored as JSON)
settings = db.Column(db.JSON, nullable=True, default=dict)
# Relationships
members = db.relationship(
"OrganizationMember", back_populates="organization", cascade="all, delete-orphan"
)
oidc_clients = db.relationship(
"OIDCClient", back_populates="organization", cascade="all, delete-orphan"
)
external_provider_configs = db.relationship(
"ExternalProviderConfig", back_populates="organization", cascade="all, delete-orphan"
)
security_policy = db.relationship(
"OrganizationSecurityPolicy",
back_populates="organization",
uselist=False,
cascade="all, delete-orphan",
foreign_keys="OrganizationSecurityPolicy.organization_id",
)
departments = db.relationship(
"Department", back_populates="organization", cascade="all, delete-orphan"
)
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."""
return f"<Organization {self.name}>"
def get_member_count(self):
"""Get the count of active members in the organization."""
return len([m for m in self.members if m.deleted_at is None])
def get_owner(self):
"""Get the owner of the organization."""
from gatehouse_app.utils.constants import OrganizationRole
for member in self.members:
if member.role == OrganizationRole.OWNER and member.deleted_at is None:
return member.user
return None
def is_member(self, user_id: str) -> bool:
"""Check if a user is a member of the organization."""
from gatehouse_app.models.organization.organization_member import OrganizationMember
return (
OrganizationMember.query.filter_by(
user_id=user_id, organization_id=self.id, deleted_at=None
).first()
is not None
)
@@ -0,0 +1,55 @@
"""Organization member model."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
from gatehouse_app.utils.constants import OrganizationRole
class OrganizationMember(BaseModel):
"""Organization member model representing user membership in an organization."""
__tablename__ = "organization_members"
user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True)
organization_id = db.Column(
db.String(36), db.ForeignKey("organizations.id"), nullable=False, index=True
)
role = db.Column(
db.Enum(OrganizationRole), default=OrganizationRole.MEMBER, nullable=False
)
invited_by_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=True)
invited_at = db.Column(db.DateTime, nullable=True)
joined_at = db.Column(db.DateTime, nullable=True)
# Relationships
user = db.relationship(
"User", foreign_keys=[user_id], back_populates="organization_memberships"
)
organization = db.relationship("Organization", back_populates="members")
invited_by = db.relationship("User", foreign_keys=[invited_by_id])
__table_args__ = (
db.UniqueConstraint("user_id", "organization_id", name="uix_user_org"),
)
def __repr__(self):
"""String representation of OrganizationMember."""
return (
f"<OrganizationMember user_id={self.user_id} "
f"org_id={self.organization_id} role={self.role}>"
)
def is_owner(self) -> bool:
"""Check if member is an owner."""
return self.role == OrganizationRole.OWNER
def is_admin(self) -> bool:
"""Check if member is an admin or owner."""
return self.role in [OrganizationRole.OWNER, OrganizationRole.ADMIN]
def can_manage_members(self) -> bool:
"""Check if member can manage other members."""
return self.is_admin()
def can_delete_organization(self) -> bool:
"""Check if member can delete the organization."""
return self.is_owner()
@@ -0,0 +1,215 @@
"""Principal and PrincipalMembership models."""
from gatehouse_app.extensions import db
from gatehouse_app.models.base import BaseModel
class Principal(BaseModel):
"""Principal model representing an SSH principal (access level/role).
In SSH CA terminology, a principal is a string like "eng-prod-servers" or
"devops-admins" that represents a set of machines or access level. Users
can be granted access to principals, either directly or via department
membership.
Example:
- Principal: "eng-prod-servers"
- Users with this principal can SSH to prod servers
- Can be assigned to departments or directly to users
"""
__tablename__ = "principals"
organization_id = db.Column(
db.String(36),
db.ForeignKey("organizations.id"),
nullable=False,
index=True,
)
name = db.Column(db.String(255), nullable=False, index=True)
description = db.Column(db.Text, nullable=True)
# Relationships
organization = db.relationship("Organization", back_populates="principals")
memberships = db.relationship(
"PrincipalMembership",
back_populates="principal",
cascade="all, delete-orphan",
)
department_links = db.relationship(
"DepartmentPrincipal",
back_populates="principal",
cascade="all, delete-orphan",
)
__table_args__ = (
db.UniqueConstraint("organization_id", "name", name="uix_org_principal_name"),
)
def __repr__(self):
"""String representation of Principal."""
return f"<Principal {self.name} (org_id={self.organization_id})>"
def to_dict(self, exclude=None):
"""Convert principal to dictionary."""
exclude = exclude or []
data = super().to_dict(exclude=exclude)
data["direct_member_count"] = len(
[m for m in self.memberships if m.deleted_at is None]
)
data["department_count"] = len(
[d for d in self.department_links if d.deleted_at is None]
)
return data
def get_members(self, active_only: bool = True):
"""Get all users who are directly assigned to this principal.
Does NOT include users who get access via department membership.
Args:
active_only: If True, exclude soft-deleted members
Returns:
List of PrincipalMembership objects
"""
if active_only:
return [m for m in self.memberships if m.deleted_at is None]
return list(self.memberships)
def get_all_members(self, active_only: bool = True):
"""Get all users who have access to this principal.
Includes both direct members and users via department membership.
Args:
active_only: If True, exclude soft-deleted members
Returns:
Set of User objects with access to this principal
"""
all_users: set = set()
# Direct members
for membership in self.get_members(active_only=active_only):
if not active_only or membership.user.deleted_at is None:
all_users.add(membership.user)
# Members via department assignment
for dept_link in self.department_links:
if dept_link.deleted_at is None or not active_only:
for dept_member in dept_link.department.get_members(active_only=active_only):
if not active_only or dept_member.user.deleted_at is None:
all_users.add(dept_member.user)
return all_users
def get_departments(self, active_only: bool = True):
"""Get all departments this principal is assigned to.
Args:
active_only: If True, exclude soft-deleted departments
Returns:
List of Department objects
"""
if active_only:
return [
d.department
for d in self.department_links
if d.deleted_at is None and d.department.deleted_at is None
]
return [d.department for d in self.department_links]
def is_member(self, user_id: str, include_via_department: bool = True) -> bool:
"""Check if a user has access to this principal.
Args:
user_id: ID of the user to check
include_via_department: If True, check department memberships too
Returns:
True if user has access to this principal
"""
# Check direct membership
has_direct = (
PrincipalMembership.query.filter_by(
user_id=user_id,
principal_id=self.id,
deleted_at=None,
).first()
is not None
)
if has_direct:
return True
if not include_via_department:
return False
# Check department membership
dept_ids = [d.id for d in self.get_departments(active_only=True)]
if not dept_ids:
return False
from gatehouse_app.models.organization.department import DepartmentMembership
return (
DepartmentMembership.query.filter(
DepartmentMembership.user_id == user_id,
DepartmentMembership.department_id.in_(dept_ids),
DepartmentMembership.deleted_at.is_(None),
).first()
is not None
)
def get_member_count(self, include_via_department: bool = True) -> int:
"""Get the count of active members with access to this principal.
Args:
include_via_department: If True, include members via department
Returns:
Count of members
"""
if not include_via_department:
return len(self.get_members(active_only=True))
return len(self.get_all_members(active_only=True))
class PrincipalMembership(BaseModel):
"""Principal membership model representing direct user assignment to a principal.
When a user is assigned directly to a principal, they get access to that
principal for SSH authentication. This is in addition to any principals
they get via department membership.
"""
__tablename__ = "principal_memberships"
user_id = db.Column(
db.String(36),
db.ForeignKey("users.id"),
nullable=False,
index=True,
)
principal_id = db.Column(
db.String(36),
db.ForeignKey("principals.id"),
nullable=False,
index=True,
)
# Relationships
user = db.relationship("User", back_populates="principal_memberships")
principal = db.relationship("Principal", back_populates="memberships")
__table_args__ = (
db.UniqueConstraint("user_id", "principal_id", name="uix_user_principal"),
)
def __repr__(self):
"""String representation of PrincipalMembership."""
return (
f"<PrincipalMembership user_id={self.user_id} "
f"principal_id={self.principal_id}>"
)