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>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
"""Shared API dependencies."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.db import get_session
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.user_service import get_user
|
||||||
|
|
||||||
|
SessionDep = Annotated[AsyncSession, Depends(get_session)]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
session: SessionDep,
|
||||||
|
x_user_id: Annotated[uuid.UUID | None, Header()] = None,
|
||||||
|
) -> User:
|
||||||
|
"""TEMPORARY pre-auth shim: identifies the caller via the ``X-User-Id``
|
||||||
|
header. Replaced by the AuthProvider (sessions/tokens) in the auth slice.
|
||||||
|
The assistant principal will also be minted here, scoped to its user."""
|
||||||
|
if x_user_id is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_401_UNAUTHORIZED, "X-User-Id header required (pre-auth)"
|
||||||
|
)
|
||||||
|
user = await get_user(session, x_user_id)
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "unknown user")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
CurrentUser = Annotated[User, Depends(get_current_user)]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Versioned API surface. Mounts under /api/v1."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import persons, trees, users
|
||||||
|
|
||||||
|
api_router = APIRouter(prefix="/api/v1")
|
||||||
|
api_router.include_router(users.router)
|
||||||
|
api_router.include_router(trees.router)
|
||||||
|
api_router.include_router(persons.router)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.person import PersonCreate, PersonRead
|
||||||
|
from app.services import person_service, tree_service
|
||||||
|
|
||||||
|
# Persons are nested under their tree (the tenant boundary).
|
||||||
|
router = APIRouter(prefix="/trees", tags=["persons"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{tree_id}/persons",
|
||||||
|
response_model=PersonRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_person(
|
||||||
|
tree_id: uuid.UUID, data: PersonCreate, session: SessionDep, current: CurrentUser
|
||||||
|
) -> PersonRead:
|
||||||
|
# get_tree enforces existence + view access; create_person enforces edit rights.
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
person = await person_service.create_person(
|
||||||
|
session,
|
||||||
|
actor=current,
|
||||||
|
tree=tree,
|
||||||
|
given=data.given,
|
||||||
|
surname=data.surname,
|
||||||
|
gender=data.gender,
|
||||||
|
is_living=data.is_living,
|
||||||
|
privacy_setting=data.privacy,
|
||||||
|
notes=data.notes,
|
||||||
|
)
|
||||||
|
return PersonRead.model_validate(person)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}/persons", response_model=list[PersonRead])
|
||||||
|
async def list_persons(
|
||||||
|
tree_id: uuid.UUID, session: SessionDep, current: CurrentUser
|
||||||
|
) -> list[PersonRead]:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
persons = await person_service.list_persons(session, viewer_id=current.id, tree=tree)
|
||||||
|
return [PersonRead.model_validate(p) for p in persons]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.tree import TreeCreate, TreeRead
|
||||||
|
from app.services import tree_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/trees", tags=["trees"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=TreeRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_tree(data: TreeCreate, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.create_tree(
|
||||||
|
session,
|
||||||
|
owner=current,
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
visibility=data.visibility,
|
||||||
|
)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[TreeRead])
|
||||||
|
async def list_my_trees(session: SessionDep, current: CurrentUser) -> list[TreeRead]:
|
||||||
|
trees = await tree_service.list_trees_for_user(session, user=current)
|
||||||
|
return [TreeRead.model_validate(t) for t in trees]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{tree_id}", response_model=TreeRead)
|
||||||
|
async def get_tree(tree_id: uuid.UUID, session: SessionDep, current: CurrentUser) -> TreeRead:
|
||||||
|
tree = await tree_service.get_tree(session, viewer_id=current.id, tree_id=tree_id)
|
||||||
|
return TreeRead.model_validate(tree)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from fastapi import APIRouter, status
|
||||||
|
|
||||||
|
from app.api.deps import CurrentUser, SessionDep
|
||||||
|
from app.schemas.user import UserCreate, UserRead
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/users", tags=["users"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_user(data: UserCreate, session: SessionDep) -> UserRead:
|
||||||
|
# Open dev bootstrap until the auth slice; lets us create tree owners.
|
||||||
|
user = await user_service.create_user(
|
||||||
|
session, email=data.email, display_name=data.display_name
|
||||||
|
)
|
||||||
|
return UserRead.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=UserRead)
|
||||||
|
async def read_me(current: CurrentUser) -> UserRead:
|
||||||
|
return UserRead.model_validate(current)
|
||||||
+30
-9
@@ -1,22 +1,43 @@
|
|||||||
"""Async database engine.
|
"""Async database engine, session factory, and the FastAPI session dependency.
|
||||||
|
|
||||||
A single lazily-created async engine for the process. The repository layer
|
The repository layer builds on ``get_session``; ``get_engine`` also backs the
|
||||||
(coming with the data model) will build sessions on top of this; for now it
|
readiness probe. Everything is lazy so importing the app never opens a
|
||||||
backs the readiness probe.
|
connection (important for tests and for ``--help``-style invocations).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
|
||||||
_engine: AsyncEngine | None = None
|
_engine: AsyncEngine | None = None
|
||||||
|
_sessionmaker: async_sessionmaker[AsyncSession] | None = None
|
||||||
|
|
||||||
|
|
||||||
def get_engine() -> AsyncEngine:
|
def get_engine() -> AsyncEngine:
|
||||||
global _engine
|
global _engine
|
||||||
if _engine is None:
|
if _engine is None:
|
||||||
_engine = create_async_engine(
|
_engine = create_async_engine(get_settings().database_url, pool_pre_ping=True)
|
||||||
get_settings().database_url,
|
|
||||||
pool_pre_ping=True,
|
|
||||||
)
|
|
||||||
return _engine
|
return _engine
|
||||||
|
|
||||||
|
|
||||||
|
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||||
|
global _sessionmaker
|
||||||
|
if _sessionmaker is None:
|
||||||
|
_sessionmaker = async_sessionmaker(
|
||||||
|
get_engine(), expire_on_commit=False, class_=AsyncSession
|
||||||
|
)
|
||||||
|
return _sessionmaker
|
||||||
|
|
||||||
|
|
||||||
|
async def get_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
"""FastAPI dependency. One session per request; commits are explicit in the
|
||||||
|
service layer."""
|
||||||
|
async with get_sessionmaker()() as session:
|
||||||
|
yield session
|
||||||
|
|||||||
+23
-4
@@ -1,14 +1,31 @@
|
|||||||
"""FastAPI application entrypoint.
|
"""FastAPI application entrypoint.
|
||||||
|
|
||||||
Thin by design: wire settings and routers, expose the OpenAPI contract. All
|
Thin by design: wire settings, routers, and error handling, and expose the
|
||||||
domain logic lives in the service layer (added with the data model). The
|
OpenAPI contract. All domain logic lives in the service layer; the privacy
|
||||||
versioned API will mount under ``/api/v1``; health probes stay at the root.
|
engine is the single enforcement point for reads.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from app.api.health import router as health_router
|
from app.api.health import router as health_router
|
||||||
|
from app.api.v1 import api_router
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.services.exceptions import Conflict, Forbidden, NotFound
|
||||||
|
|
||||||
|
|
||||||
|
def _register_error_handlers(app: FastAPI) -> None:
|
||||||
|
@app.exception_handler(NotFound)
|
||||||
|
async def _not_found(request: Request, exc: NotFound) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=404, content={"detail": str(exc) or "not found"})
|
||||||
|
|
||||||
|
@app.exception_handler(Forbidden)
|
||||||
|
async def _forbidden(request: Request, exc: Forbidden) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=403, content={"detail": str(exc) or "forbidden"})
|
||||||
|
|
||||||
|
@app.exception_handler(Conflict)
|
||||||
|
async def _conflict(request: Request, exc: Conflict) -> JSONResponse:
|
||||||
|
return JSONResponse(status_code=409, content={"detail": str(exc) or "conflict"})
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -19,6 +36,8 @@ def create_app() -> FastAPI:
|
|||||||
description="Provenance API — family and land provenance.",
|
description="Provenance API — family and land provenance.",
|
||||||
)
|
)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(api_router)
|
||||||
|
_register_error_handlers(app)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Thin data-access layer over SQLAlchemy. No business rules live here — the
|
||||||
|
service layer owns those (and the privacy engine). The repository only knows how
|
||||||
|
to fetch and stage rows, transparently excluding soft-deleted ones.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRepository:
|
||||||
|
def __init__(self, session: AsyncSession, model: type) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.model = model
|
||||||
|
|
||||||
|
def _exclude_deleted(self, stmt):
|
||||||
|
if hasattr(self.model, "deleted_at"):
|
||||||
|
stmt = stmt.where(self.model.deleted_at.is_(None))
|
||||||
|
return stmt
|
||||||
|
|
||||||
|
async def get(self, id_: Any, *, include_deleted: bool = False):
|
||||||
|
stmt = select(self.model).where(self.model.id == id_)
|
||||||
|
if not include_deleted:
|
||||||
|
stmt = self._exclude_deleted(stmt)
|
||||||
|
return (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list(self, *conditions, include_deleted: bool = False, order_by=None):
|
||||||
|
stmt = select(self.model)
|
||||||
|
for condition in conditions:
|
||||||
|
stmt = stmt.where(condition)
|
||||||
|
if not include_deleted:
|
||||||
|
stmt = self._exclude_deleted(stmt)
|
||||||
|
if order_by is not None:
|
||||||
|
stmt = stmt.order_by(order_by)
|
||||||
|
return list((await self.session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
def add(self, obj):
|
||||||
|
self.session.add(obj)
|
||||||
|
return obj
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import PersonPrivacy
|
||||||
|
|
||||||
|
|
||||||
|
class PersonCreate(BaseModel):
|
||||||
|
given: str | None = None
|
||||||
|
surname: str | None = None
|
||||||
|
gender: str | None = None
|
||||||
|
is_living: bool | None = None
|
||||||
|
privacy: PersonPrivacy = PersonPrivacy.inherit
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PersonRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
tree_id: uuid.UUID
|
||||||
|
primary_name: str | None = None
|
||||||
|
gender: str | None
|
||||||
|
is_living: bool | None
|
||||||
|
privacy: PersonPrivacy
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
from app.models.enums import TreeVisibility
|
||||||
|
|
||||||
|
|
||||||
|
class TreeCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
visibility: TreeVisibility = TreeVisibility.private
|
||||||
|
|
||||||
|
|
||||||
|
class TreeRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
visibility: TreeVisibility
|
||||||
|
owner_id: uuid.UUID
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
# Note: email is a plain str for now (no email-validator dependency yet); the
|
||||||
|
# auth slice can tighten this to EmailStr.
|
||||||
|
|
||||||
|
|
||||||
|
class UserCreate(BaseModel):
|
||||||
|
email: str
|
||||||
|
display_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserRead(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: uuid.UUID
|
||||||
|
email: str
|
||||||
|
display_name: str | None
|
||||||
|
email_verified_at: datetime | None
|
||||||
|
created_at: datetime
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Audit logging. Every mutation records an append-only AuditEntry attributing
|
||||||
|
the change to a User (or the assistant principal acting for a User). Staged on
|
||||||
|
the session; the caller commits as part of its unit of work.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.audit import AuditEntry
|
||||||
|
from app.models.enums import AuditActorType
|
||||||
|
|
||||||
|
|
||||||
|
def record_audit(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: uuid.UUID | None = None,
|
||||||
|
tree_id: uuid.UUID | None = None,
|
||||||
|
actor_user_id: uuid.UUID | None = None,
|
||||||
|
actor_type: AuditActorType = AuditActorType.user,
|
||||||
|
before: dict | None = None,
|
||||||
|
after: dict | None = None,
|
||||||
|
) -> AuditEntry:
|
||||||
|
entry = AuditEntry(
|
||||||
|
action=action,
|
||||||
|
entity_type=entity_type,
|
||||||
|
entity_id=entity_id,
|
||||||
|
tree_id=tree_id,
|
||||||
|
actor_user_id=actor_user_id,
|
||||||
|
actor_type=actor_type,
|
||||||
|
before=before,
|
||||||
|
after=after,
|
||||||
|
)
|
||||||
|
session.add(entry)
|
||||||
|
return entry
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Domain errors. The API layer maps these to HTTP status codes so services
|
||||||
|
stay transport-agnostic."""
|
||||||
|
|
||||||
|
|
||||||
|
class DomainError(Exception):
|
||||||
|
"""Base for domain-level errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFound(DomainError):
|
||||||
|
"""Requested entity does not exist (or is soft-deleted / not visible)."""
|
||||||
|
|
||||||
|
|
||||||
|
class Forbidden(DomainError):
|
||||||
|
"""Caller lacks the required role for this action."""
|
||||||
|
|
||||||
|
|
||||||
|
class Conflict(DomainError):
|
||||||
|
"""Operation conflicts with current state (e.g. duplicate email)."""
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Person service. Writes require editor rights on the tree; reads run every
|
||||||
|
person through the privacy engine. Each returned Person gets a transient
|
||||||
|
``primary_name`` for display (not persisted).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import PersonPrivacy
|
||||||
|
from app.models.person import Name, Person
|
||||||
|
from app.models.tree import Tree
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services import privacy
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Forbidden
|
||||||
|
from app.services.privacy import Visibility
|
||||||
|
|
||||||
|
|
||||||
|
def _format_name(name: Name) -> str | None:
|
||||||
|
parts = [name.given, name.surname]
|
||||||
|
joined = " ".join(p for p in parts if p)
|
||||||
|
return joined or name.display_name
|
||||||
|
|
||||||
|
|
||||||
|
async def _attach_primary_name(session: AsyncSession, person: Person) -> None:
|
||||||
|
stmt = (
|
||||||
|
select(Name)
|
||||||
|
.where(Name.person_id == person.id, Name.deleted_at.is_(None))
|
||||||
|
.order_by(Name.is_primary.desc(), Name.sort_order)
|
||||||
|
)
|
||||||
|
name = (await session.execute(stmt)).scalars().first()
|
||||||
|
# Transient display attribute consumed by the PersonRead schema.
|
||||||
|
person.primary_name = _format_name(name) if name is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
async def create_person(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
tree: Tree,
|
||||||
|
given: str | None = None,
|
||||||
|
surname: str | None = None,
|
||||||
|
gender: str | None = None,
|
||||||
|
is_living: bool | None = None,
|
||||||
|
privacy_setting: PersonPrivacy = PersonPrivacy.inherit,
|
||||||
|
notes: str | None = None,
|
||||||
|
) -> Person:
|
||||||
|
if not await privacy.can_edit_tree(session, user_id=actor.id, tree=tree):
|
||||||
|
raise Forbidden("not an editor of this tree")
|
||||||
|
|
||||||
|
person = Person(
|
||||||
|
tree_id=tree.id,
|
||||||
|
gender=gender,
|
||||||
|
is_living=is_living,
|
||||||
|
privacy=privacy_setting,
|
||||||
|
notes=notes,
|
||||||
|
)
|
||||||
|
session.add(person)
|
||||||
|
await session.flush() # assign person.id
|
||||||
|
|
||||||
|
if given or surname:
|
||||||
|
session.add(
|
||||||
|
Name(
|
||||||
|
tree_id=tree.id,
|
||||||
|
person_id=person.id,
|
||||||
|
name_type="birth",
|
||||||
|
given=given,
|
||||||
|
surname=surname,
|
||||||
|
is_primary=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="Person",
|
||||||
|
entity_id=person.id,
|
||||||
|
tree_id=tree.id,
|
||||||
|
actor_user_id=actor.id,
|
||||||
|
after={"given": given, "surname": surname},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(person)
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
return person
|
||||||
|
|
||||||
|
|
||||||
|
async def list_persons(
|
||||||
|
session: AsyncSession, *, viewer_id: uuid.UUID, tree: Tree
|
||||||
|
) -> list[Person]:
|
||||||
|
if not await privacy.can_view_tree(session, user_id=viewer_id, tree=tree):
|
||||||
|
raise Forbidden("not permitted to view this tree")
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(Person)
|
||||||
|
.where(Person.tree_id == tree.id, Person.deleted_at.is_(None))
|
||||||
|
.order_by(Person.created_at)
|
||||||
|
)
|
||||||
|
persons = list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
visible: list[Person] = []
|
||||||
|
for person in persons:
|
||||||
|
if (
|
||||||
|
await privacy.person_visibility(
|
||||||
|
session, user_id=viewer_id, tree=tree, person=person
|
||||||
|
)
|
||||||
|
== Visibility.hidden
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
await _attach_primary_name(session, person)
|
||||||
|
visible.append(person)
|
||||||
|
return visible
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""The privacy engine — the single enforcement point for visibility.
|
||||||
|
|
||||||
|
INVARIANT (CLAUDE.md #2): every read path resolves visibility here. Do not add a
|
||||||
|
query path that returns rows to a caller without first passing through this
|
||||||
|
module. Effective visibility is a function of the viewer's role on the tree, the
|
||||||
|
tree's visibility, the per-person override, and (Phase 2) living-person status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import enum
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.enums import MembershipRole, PersonPrivacy, TreeVisibility
|
||||||
|
from app.models.person import Person
|
||||||
|
from app.models.tree import Tree, TreeMembership
|
||||||
|
|
||||||
|
|
||||||
|
class Visibility(enum.StrEnum):
|
||||||
|
full = "full"
|
||||||
|
redacted = "redacted"
|
||||||
|
hidden = "hidden"
|
||||||
|
|
||||||
|
|
||||||
|
async def get_membership_role(
|
||||||
|
session: AsyncSession, user_id: uuid.UUID | None, tree_id: uuid.UUID
|
||||||
|
) -> MembershipRole | None:
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
|
stmt = select(TreeMembership.role).where(
|
||||||
|
TreeMembership.tree_id == tree_id,
|
||||||
|
TreeMembership.user_id == user_id,
|
||||||
|
)
|
||||||
|
return (await session.execute(stmt)).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
async def can_view_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||||
|
if tree.deleted_at is not None:
|
||||||
|
return False
|
||||||
|
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||||
|
return True
|
||||||
|
return tree.visibility in (TreeVisibility.public, TreeVisibility.unlisted)
|
||||||
|
|
||||||
|
|
||||||
|
async def can_edit_tree(session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree) -> bool:
|
||||||
|
role = await get_membership_role(session, user_id, tree.id)
|
||||||
|
return role in (MembershipRole.owner, MembershipRole.editor)
|
||||||
|
|
||||||
|
|
||||||
|
async def person_visibility(
|
||||||
|
session: AsyncSession, *, user_id: uuid.UUID | None, tree: Tree, person: Person
|
||||||
|
) -> Visibility:
|
||||||
|
if not await can_view_tree(session, user_id=user_id, tree=tree):
|
||||||
|
return Visibility.hidden
|
||||||
|
if await get_membership_role(session, user_id, tree.id) is not None:
|
||||||
|
return Visibility.full
|
||||||
|
# Non-member viewing a public/unlisted tree:
|
||||||
|
if person.privacy == PersonPrivacy.private:
|
||||||
|
return Visibility.hidden
|
||||||
|
# TODO(Phase 2): redact living people for non-members (ARCHITECTURE §6).
|
||||||
|
return Visibility.full
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""User service. Account creation here is a temporary, open dev bootstrap so we
|
||||||
|
can create tree owners before the auth slice exists; the auth slice replaces it
|
||||||
|
with the AuthProvider (password/OIDC/social) and proper verification.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.base import BaseRepository
|
||||||
|
from app.services.audit import record_audit
|
||||||
|
from app.services.exceptions import Conflict
|
||||||
|
|
||||||
|
|
||||||
|
async def create_user(
|
||||||
|
session: AsyncSession, *, email: str, display_name: str | None = None
|
||||||
|
) -> User:
|
||||||
|
email = email.strip().lower()
|
||||||
|
existing = (
|
||||||
|
await session.execute(select(User).where(User.email == email))
|
||||||
|
).scalar_one_or_none()
|
||||||
|
if existing is not None:
|
||||||
|
raise Conflict("email already registered")
|
||||||
|
|
||||||
|
user = User(email=email, display_name=display_name)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush() # assign user.id
|
||||||
|
record_audit(
|
||||||
|
session,
|
||||||
|
action="create",
|
||||||
|
entity_type="User",
|
||||||
|
entity_id=user.id,
|
||||||
|
actor_user_id=user.id,
|
||||||
|
after={"email": email},
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(user)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user(session: AsyncSession, user_id: uuid.UUID) -> User | None:
|
||||||
|
return await BaseRepository(session, User).get(user_id)
|
||||||
Reference in New Issue
Block a user