Files
saas_backend/app/routes/auth/org_unit.py
T

449 lines
15 KiB
Python
Raw Normal View History

2026-08-31 20:04:12 -04:00
"""Managing a workspace's organisational structure.
Everything is scoped to the caller's workspace by their session. Structure is
managed by whoever can manage roles — it is the same kind of act, deciding how
the workspace is shaped — while *membership* follows user management, because
putting somebody in a department is an edit to that person.
"""
from __future__ import annotations
import uuid
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from app.config.database import get_db
from app.helper.helpers import get_client_ip
from app.middleware.auth_middleware import User, get_current_user, require_access
from app.models.auth.org_unit_model import OrgUnit, UserAdminScope, UserOrgUnit
from app.services.auth import org_unit_service as units
from app.services.system.audit_log_service import AuditLogService
router = APIRouter()
class OrgUnitCreate(BaseModel):
name: str = Field(min_length=1, max_length=160)
code: Optional[str] = Field(default=None, max_length=60)
parent_id: Optional[uuid.UUID] = None
class OrgUnitUpdate(BaseModel):
name: Optional[str] = Field(default=None, max_length=160)
code: Optional[str] = Field(default=None, max_length=60)
class OrgUnitMove(BaseModel):
parent_id: Optional[uuid.UUID] = None
class OrgUnitResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
code: Optional[str] = None
parent_id: Optional[uuid.UUID] = None
path: str
depth: int
is_active: bool
seat_limit: Optional[int] = None
seats_used: int = 0
class MembershipRequest(BaseModel):
user_id: uuid.UUID
primary: bool = False
lead: bool = False
class MemberResponse(BaseModel):
user_id: uuid.UUID
email: str
is_primary: bool
is_lead: bool
def _own(db: Session, unit_id: uuid.UUID, actor: User) -> OrgUnit:
unit = (
db.query(OrgUnit)
.filter(OrgUnit.id == unit_id, OrgUnit.tenant_id == actor.tenant_id)
.first()
)
if unit is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="No such unit")
return unit
def _target(db: Session, user_id: uuid.UUID, actor: User) -> User:
"""A person in the caller's workspace, and inside their administrative scope.
Both checks, and both answer 404: a scoped administrator must not be able to
tell "outside my branch" from "does not exist", or the refusal becomes a way
to enumerate the workspace.
"""
target = (
db.query(User)
.filter(User.id == user_id, User.tenant_id == actor.tenant_id,
User.deleted_at.is_(None))
.first()
)
if target is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="User not found")
units.assert_may_administer(db, actor, target)
return target
def _workspace(actor: User) -> uuid.UUID:
if actor.tenant_id is None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN,
detail="Account is not associated with a workspace")
return actor.tenant_id
def _to_response(unit: OrgUnit) -> OrgUnitResponse:
payload = OrgUnitResponse.model_validate(unit)
payload.depth = unit.depth
return payload
@router.get("", response_model=List[OrgUnitResponse])
def list_units(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.read")),
):
"""The whole tree, parents before children.
Ordered on `path`, which gives depth-first order for free — a child's path
is its parent's plus one segment.
"""
from app.services.auth import seat_allocation_service
tenant_id = _workspace(current_user)
rows = units.tree(db, tenant_id)
limits = seat_allocation_service.limits_by_unit(db, tenant_id)
used = seat_allocation_service.usage_by_unit(db, tenant_id)
listing = []
for row in rows:
item = _to_response(row)
item.seat_limit = limits.get(row.id)
item.seats_used = used.get(row.id, 0)
listing.append(item)
return listing
@router.post("", response_model=OrgUnitResponse,
status_code=status.HTTP_201_CREATED)
def create_unit(
payload: OrgUnitCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.create")),
):
unit = units.create(
db, tenant_id=_workspace(current_user), name=payload.name,
code=payload.code, parent_id=payload.parent_id,
)
AuditLogService.log(
db=db, module_name="Organisation", action_type="CREATE",
entity_id=str(unit.id), entity_name=unit.name,
description="Unit '" + unit.name + "' created",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()
db.refresh(unit)
return _to_response(unit)
@router.put("/{unit_id}", response_model=OrgUnitResponse)
def update_unit(
unit_id: uuid.UUID,
payload: OrgUnitUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")),
):
unit = units.rename(db, _own(db, unit_id, current_user),
name=payload.name, code=payload.code)
db.commit()
db.refresh(unit)
return _to_response(unit)
@router.post("/{unit_id}/move", response_model=OrgUnitResponse)
def move_unit(
unit_id: uuid.UUID,
payload: OrgUnitMove,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")),
):
"""Re-parent a unit, rewriting every descendant's path.
The expensive operation on purpose — it happens during a reorganisation, and
it is what keeps the permission check to one indexed prefix match on every
request an administrator makes.
"""
unit = _own(db, unit_id, current_user)
before = unit.path
unit = units.move(db, unit, payload.parent_id)
AuditLogService.log(
db=db, module_name="Organisation", action_type="UPDATE",
entity_id=str(unit.id), entity_name=unit.name,
description="Unit '" + unit.name + "' moved",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
old_values={"path": before}, new_values={"path": unit.path},
)
db.commit()
db.refresh(unit)
return _to_response(unit)
@router.delete("/{unit_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_unit(
unit_id: uuid.UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.delete")),
):
"""Refused while anything still depends on it.
A cascade would silently dissolve a department's whole sub-tree and quietly
widen every administrator scoped to one of them.
"""
unit = _own(db, unit_id, current_user)
name = unit.name
units.delete(db, unit)
AuditLogService.log(
db=db, module_name="Organisation", action_type="DELETE",
entity_id=str(unit_id), entity_name=name,
description="Unit '" + name + "' deleted",
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()
class SeatAllocationRequest(BaseModel):
seat_limit: Optional[int] = Field(default=None, ge=0)
class SeatSummary(BaseModel):
purchased: Optional[int] = None
allocated: int
unallocated: Optional[int] = None
@router.get("/seats", response_model=SeatSummary)
def seat_summary(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.read")),
):
"""What was bought, what is spoken for, and what is left to give.
A literal path, declared before `/{unit_id}` would matter if it shared the
shape — it does not, but keeping it above the parameterised routes is the
habit that stops the next literal path being swallowed.
"""
from app.services.auth import seat_allocation_service
return SeatSummary(**seat_allocation_service.summary(db, _workspace(current_user)))
@router.put("/{unit_id}/seats", response_model=SeatSummary)
def set_seat_allocation(
unit_id: uuid.UUID,
payload: SeatAllocationRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")),
):
"""Cap one unit, or remove its cap.
Guarded by role management rather than user management: this decides what a
branch is allowed to become, which is a shape-of-the-workspace decision
rather than an edit to a person.
"""
from app.services.auth import seat_allocation_service
tenant_id = _workspace(current_user)
unit = _own(db, unit_id, current_user)
if payload.seat_limit is None:
seat_allocation_service.remove_allocation(db, unit.id)
described = f"Seat allocation removed from unit '{unit.name}'"
else:
seat_allocation_service.set_allocation(
db, tenant_id=tenant_id, unit=unit, seat_limit=payload.seat_limit
)
described = (
f"Unit '{unit.name}' allocated {payload.seat_limit} seat(s)"
)
AuditLogService.log(
db=db, module_name="Organisation", action_type="UPDATE",
entity_id=str(unit.id), entity_name=unit.name,
description=described,
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=tenant_id,
)
db.commit()
return SeatSummary(**seat_allocation_service.summary(db, tenant_id))
@router.get("/{unit_id}/members", response_model=List[MemberResponse])
def members(
unit_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.read")),
):
unit = _own(db, unit_id, current_user)
rows = (
db.query(UserOrgUnit, User)
.join(User, User.id == UserOrgUnit.user_id)
.filter(UserOrgUnit.org_unit_id == unit.id, User.deleted_at.is_(None))
.order_by(User.email)
.all()
)
return [
MemberResponse(user_id=user.id, email=user.email,
is_primary=membership.is_primary,
is_lead=membership.is_lead)
for membership, user in rows
]
@router.post("/{unit_id}/members", status_code=status.HTTP_204_NO_CONTENT)
def add_member(
unit_id: uuid.UUID,
payload: MembershipRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.update")),
):
"""Putting somebody in a department is an edit to that person, so it follows
user management rather than structure management."""
unit = _own(db, unit_id, current_user)
target = _target(db, payload.user_id, current_user)
units.assign(db, user=target, unit=unit, primary=payload.primary,
lead=payload.lead)
db.commit()
@router.delete("/{unit_id}/members/{user_id}",
status_code=status.HTTP_204_NO_CONTENT)
def remove_member(
unit_id: uuid.UUID,
user_id: uuid.UUID,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.user.update")),
):
unit = _own(db, unit_id, current_user)
target = _target(db, user_id, current_user)
if not units.unassign(db, user=target, unit=unit):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND,
detail="That person is not in this unit")
db.commit()
@router.post("/{unit_id}/administrators", status_code=status.HTTP_204_NO_CONTENT)
def grant_scope(
unit_id: uuid.UUID,
payload: MembershipRequest,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")),
):
"""Confine somebody's user administration to this unit and everything under
it.
Guarded by role management rather than user management: this decides what
somebody else is allowed to do, which is a permissions act.
Note that this **narrows** — it never grants. Somebody with no permission to
manage users gains nothing from a scope; somebody with it loses everything
outside their scopes.
"""
unit = _own(db, unit_id, current_user)
target = _target(db, payload.user_id, current_user)
units.grant_admin_scope(db, user=target, unit=unit)
AuditLogService.log(
db=db, module_name="Organisation", action_type="UPDATE",
entity_id=str(target.id), entity_name=target.email,
description=("User administration for '" + target.email
+ "' scoped to unit '" + unit.name + "'"),
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()
@router.delete("/{unit_id}/administrators/{user_id}",
status_code=status.HTTP_204_NO_CONTENT)
def revoke_scope(
unit_id: uuid.UUID,
user_id: uuid.UUID,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
_=Depends(require_access("admin.role.update")),
):
"""Removing somebody's last scope **widens** them back to the whole
workspace, because no scopes means unrestricted. That is the correct default
— it is what every administrator is today — but it is the opposite of what
"revoke" sounds like, so it is audited as a widening."""
unit = _own(db, unit_id, current_user)
target = _target(db, user_id, current_user)
units.revoke_admin_scope(db, user=target, unit=unit)
remaining = (
db.query(UserAdminScope)
.filter(UserAdminScope.user_id == target.id)
.count()
)
AuditLogService.log(
db=db, module_name="Organisation", action_type="UPDATE",
entity_id=str(target.id), entity_name=target.email,
description=(
"Administration scope on '" + unit.name + "' removed from '"
+ target.email + "'"
+ ("" if remaining else " — they now administer the whole workspace")
),
performed_by_id=str(current_user.id),
performed_by_email=current_user.email,
ip_address=get_client_ip(request),
tenant_id=current_user.tenant_id,
)
db.commit()