Chore: Refractor Models into organized file/folder
This commit is contained in:
@@ -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}>"
|
||||
)
|
||||
Reference in New Issue
Block a user