Files
provenance/backend/app/services/tree_service.py
T
justin dffd05d303 Add layered service/API for tenancy and people with the privacy seam
Wires the data model through repository -> service -> API/v1. The privacy engine (app/services/privacy.py) is the single enforcement point: every read resolves visibility there (tree role, tree visibility, per-person override; living-person redaction is a marked Phase 2 TODO). All writes record an attributable AuditEntry.

Endpoints: POST /users (open dev bootstrap until auth), GET /users/me, POST/GET /trees, GET /trees/{id}, and POST/GET /trees/{id}/persons. Authn is a temporary X-User-Id header shim; authz is membership-based (owner/editor/viewer). Domain errors map to 401/403/404/409. Verified on the deploy target: private tree -> 403 for non-members, missing actor -> 401, audit log populated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
2026-06-06 10:40:19 -04:00

62 lines
2.0 KiB
Python

"""Tree service. Creating a tree also creates the owner's TreeMembership (the
authorization basis) and an audit entry. Reads go through the privacy engine.
"""
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.enums import MembershipRole, TreeVisibility
from app.models.tree import Tree, TreeMembership
from app.models.user import User
from app.repositories.base import BaseRepository
from app.services import privacy
from app.services.audit import record_audit
from app.services.exceptions import Forbidden, NotFound
async def create_tree(
session: AsyncSession,
*,
owner: User,
name: str,
description: str | None = None,
visibility: TreeVisibility = TreeVisibility.private,
) -> Tree:
tree = Tree(owner_id=owner.id, name=name, description=description, visibility=visibility)
session.add(tree)
await session.flush() # assign tree.id
session.add(TreeMembership(tree_id=tree.id, user_id=owner.id, role=MembershipRole.owner))
record_audit(
session,
action="create",
entity_type="Tree",
entity_id=tree.id,
tree_id=tree.id,
actor_user_id=owner.id,
after={"name": name, "visibility": visibility.value},
)
await session.commit()
await session.refresh(tree)
return tree
async def list_trees_for_user(session: AsyncSession, *, user: User) -> list[Tree]:
stmt = (
select(Tree)
.join(TreeMembership, TreeMembership.tree_id == Tree.id)
.where(TreeMembership.user_id == user.id, Tree.deleted_at.is_(None))
.order_by(Tree.created_at)
)
return list((await session.execute(stmt)).scalars().all())
async def get_tree(session: AsyncSession, *, viewer_id: uuid.UUID, tree_id: uuid.UUID) -> Tree:
tree = await BaseRepository(session, Tree).get(tree_id)
if tree is None:
raise NotFound("tree not found")
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
raise Forbidden("not permitted to view this tree")
return tree