Chore(Feat): added principal,depart RBAC

This commit is contained in:
2026-02-27 10:03:05 +05:45
parent c623824738
commit 92fd57447d
9 changed files with 1841 additions and 1 deletions
+14
View File
@@ -20,6 +20,15 @@ from gatehouse_app.models.oidc_audit_log import OIDCAuditLog
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
from gatehouse_app.models.user_security_policy import UserSecurityPolicy
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
from gatehouse_app.models.department import (
Department,
DepartmentMembership,
DepartmentPrincipal,
)
from gatehouse_app.models.principal import (
Principal,
PrincipalMembership,
)
__all__ = [
"BaseModel",
@@ -41,4 +50,9 @@ __all__ = [
"OrganizationSecurityPolicy",
"UserSecurityPolicy",
"MfaPolicyCompliance",
"Department",
"DepartmentMembership",
"DepartmentPrincipal",
"Principal",
"PrincipalMembership",
]
+194
View File
@@ -0,0 +1,194 @@
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",
)
# Unique constraint: department name per organization
__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)
# Add member count
data["member_count"] = len([m for m in self.memberships if m.deleted_at is None])
# Add principal count
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=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 self.memberships
def get_principals(self, active_only=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):
"""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):
"""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")
# Unique constraint: user can only be member of a department once
__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")
# Unique constraint: principal can only be assigned to a department once
__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} principal_id={self.principal_id}>"
+6
View File
@@ -34,6 +34,12 @@ class Organization(BaseModel):
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"
)
def __repr__(self):
"""String representation of Organization."""
+220
View File
@@ -0,0 +1,220 @@
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",
)
# Unique constraint: principal name per organization
__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)
# Add member count
data["direct_member_count"] = len([m for m in self.memberships if m.deleted_at is None])
# Add department count
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=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 self.memberships
def get_all_members(self, active_only=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
"""
from gatehouse_app.models.user import User
all_users = set()
# Add direct members
for membership in self.get_members(active_only=active_only):
if membership.user.deleted_at is None or not active_only:
all_users.add(membership.user)
# Add 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 dept_member.user.deleted_at is None or not active_only:
all_users.add(dept_member.user)
return all_users
def get_departments(self, active_only=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, include_via_department=True):
"""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
# Check department membership if requested
if not include_via_department:
return False
# Get all departments this principal is assigned to
depts = self.get_departments(active_only=True)
dept_ids = [d.id for d in depts]
if not dept_ids:
return False
# Check if user is a member of any of these departments
from gatehouse_app.models.department import DepartmentMembership
return (
DepartmentMembership.query.filter(
DepartmentMembership.user_id == user_id,
DepartmentMembership.department_id.in_(dept_ids),
DepartmentMembership.deleted_at == None,
).first()
is not None
)
def get_member_count(self, include_via_department=True):
"""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")
# Unique constraint: user can only be member of a principal once
__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} principal_id={self.principal_id}>"
+12
View File
@@ -43,6 +43,18 @@ class User(BaseModel):
cascade="all, delete-orphan",
foreign_keys="MfaPolicyCompliance.user_id",
)
department_memberships = db.relationship(
"DepartmentMembership",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="DepartmentMembership.user_id",
)
principal_memberships = db.relationship(
"PrincipalMembership",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="PrincipalMembership.user_id",
)
def __repr__(self):
"""String representation of User."""