Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions backend/api/ledger_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Read-only active-workspace ledger entries with session-bound cursors."""

import secrets
from time import time as current_time

from django.core.signing import BadSignature, SignatureExpired, TimestampSigner
from django.db.models import F, Q
from django.utils.dateparse import parse_date
from django.utils.decorators import method_decorator

from ledger.services import LedgerAccessDenied, get_ledger_entries
from workspaces.context import WorkspaceContextError, resolve_active_workspace_context
from workspaces.permissions import WorkspacePermissionDenied

from .auth_views import AUTH_EXPIRY_SESSION_KEY, JsonMethodView, require_api_auth
from .http import json_data, json_error


CURSOR_PAGE_SIZE = 25
CURSOR_MAX_ENTRIES = 128
CURSOR_SESSION_KEY = "api.ledger_entries.cursors"
CURSOR_VERSION = "v1"
CURSOR_SIGNER = TimestampSigner(salt="api.ledger_entries.cursor.v1")
READ_FIELDS = (
"public_id",
"direction",
"source",
"amount",
"currency",
"occurred_on",
"description",
"category_name_snapshot",
"category_deductible_snapshot",
"client_public_id",
"project_public_id",
"created_at",
)


def _cursor_error():
return json_error("invalid_request", status=400)


def _request_cursor(request):
if not request.GET:
return None, None
if set(request.GET) != {"cursor"} or len(request.GET.getlist("cursor")) != 1:
return None, _cursor_error()
value = request.GET["cursor"]
return (value, None) if value else (None, _cursor_error())


def _read_cursor(request, context, value):
try:
version, nonce = CURSOR_SIGNER.unsign(value).split(".", 1)
if version != CURSOR_VERSION or not nonce:
raise ValueError
cursors = request.session.get(CURSOR_SESSION_KEY)
stored = cursors.get(nonce) if isinstance(cursors, dict) else None
deadline = request.session.get(AUTH_EXPIRY_SESSION_KEY)
if (
not isinstance(stored, dict)
or stored.get("subject") != str(request.user.pk)
or stored.get("workspace") != str(context.workspace.public_id)
or stored.get("membership") != context.membership.pk
or stored.get("deadline") != deadline
or not isinstance(deadline, (int, float))
or deadline <= current_time()
):
raise ValueError
occurred_on = parse_date(stored.get("occurred_on", ""))
pk = stored.get("pk")
if occurred_on is None or not isinstance(pk, int) or pk <= 0:
raise ValueError
return occurred_on, pk, None
except (BadSignature, SignatureExpired, ValueError, TypeError, AttributeError):
return None, None, _cursor_error()


def _new_cursor(request, context, row):
nonce = secrets.token_urlsafe(24)
deadline = request.session.get(AUTH_EXPIRY_SESSION_KEY)
now = current_time()
cursors = request.session.get(CURSOR_SESSION_KEY)
if not isinstance(cursors, dict):
cursors = {}
cursors = {
key: value
for key, value in cursors.items()
if isinstance(value, dict)
and value.get("deadline") == deadline
and isinstance(deadline, (int, float))
and deadline > now
}
while len(cursors) >= CURSOR_MAX_ENTRIES:
cursors.pop(next(iter(cursors)))
cursors[nonce] = {
"occurred_on": row["occurred_on"].isoformat(),
"pk": row["pk"],
"workspace": str(context.workspace.public_id),
"membership": context.membership.pk,
"subject": str(request.user.pk),
"deadline": deadline,
}
request.session[CURSOR_SESSION_KEY] = cursors
return CURSOR_SIGNER.sign(f"{CURSOR_VERSION}.{nonce}")


def _serialize(row):
return {field: row[field] for field in READ_FIELDS}


class LedgerEntryListView(JsonMethodView):
@method_decorator(require_api_auth)
def get(self, request):
cursor_value, error_response = _request_cursor(request)
if error_response is not None:
return error_response
try:
context = resolve_active_workspace_context(request)
entries = get_ledger_entries(context)
except (LedgerAccessDenied, WorkspacePermissionDenied):
return json_error("permission_denied", status=403)
except WorkspaceContextError:
return json_error("workspace_required", status=400)
if cursor_value is not None:
cursor_occurred_on, cursor_pk, error_response = _read_cursor(request, context, cursor_value)
if error_response is not None:
return error_response
else:
cursor_occurred_on = cursor_pk = None
entries = entries.annotate(
client_public_id=F("client__public_id"),
project_public_id=F("project__public_id"),
).order_by("-occurred_on", "-pk")
if cursor_value is not None:
entries = entries.filter(
Q(occurred_on__lt=cursor_occurred_on)
| Q(occurred_on=cursor_occurred_on, pk__lt=cursor_pk)
)
rows = list(entries.values(*READ_FIELDS, "pk")[: CURSOR_PAGE_SIZE + 1])
page = rows[:CURSOR_PAGE_SIZE]
return json_data({
"items": [_serialize(row) for row in page],
"next_cursor": _new_cursor(request, context, page[-1]) if len(rows) > CURSOR_PAGE_SIZE else None,
})
202 changes: 202 additions & 0 deletions backend/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1817,3 +1817,205 @@ def test_rejects_invalid_query_contract_and_non_get_methods_as_json(self, mocked
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json(), {"error": {"code": "method_not_allowed"}})
self.assertEqual(response["Allow"], "GET, HEAD, OPTIONS")


class LedgerEntryApiTests(TestCase):
def setUp(self):
self.user = get_user_model().objects.create_user(
email="ledger-api@example.com", password="correct-horse-battery-staple"
)
self.other_user = get_user_model().objects.create_user(
email="ledger-other-api@example.com", password="correct-horse-battery-staple"
)
self.workspace = Workspace.objects.create(name="Ledger API Studio", slug="ledger-api-studio")
self.membership = Membership.objects.create(
workspace=self.workspace, user=self.user, role=Membership.Role.OWNER
)
self.context = ActiveWorkspaceContext(self.workspace, self.membership)
self.category = create_category(
self.context, name="Travel", description="Travel expenses",
default_deductible=True, monthly_budget=Decimal("50.00"), status=Category.Status.ACTIVE,
)

def authenticate(self, *, expires_at=1_000_100, workspace=None, user=None):
self.client.force_login(user or self.user)
session = self.client.session
session["api.auth_expires_at"] = expires_at
if workspace is not None:
session["workspaces.active_workspace_public_id"] = str(workspace.public_id)
session.save()

def make_entry(self, *, occurred_on=date(2024, 1, 1), context=None, category=None, client=None, project=None):
from ledger.models import LedgerEntry
from ledger.services import record_manual_entry

return record_manual_entry(
context or self.context, idempotency_key=uuid4(), direction=LedgerEntry.Direction.EXPENSE,
amount=Decimal("12.50"), occurred_on=occurred_on, description="Taxi ride",
category=category or self.category, client=client, project=project,
)

@patch("api.auth_views.time.time", return_value=1_000_000)
def test_requires_live_session_fresh_context_and_operational_membership(self, mocked_time):
self.assertEqual(self.client.get("/api/v1/ledger-entries/").status_code, 401)
self.authenticate(expires_at=999_999, workspace=self.workspace)
self.assertEqual(self.client.get("/api/v1/ledger-entries/").status_code, 401)
self.authenticate()
self.assertEqual(self.client.get("/api/v1/ledger-entries/").json(), {"error": {"code": "workspace_required"}})
self.authenticate(workspace=self.workspace)
with allow_membership_writes():
Membership.objects.filter(pk=self.membership.pk).update(role=Membership.Role.ADMINISTRATIVE)
self.assertEqual(self.client.get("/api/v1/ledger-entries/").status_code, 403)
with allow_membership_writes():
Membership.objects.filter(pk=self.membership.pk).update(role=Membership.Role.OPERATIONAL)
self.assertEqual(self.client.get("/api/v1/ledger-entries/").status_code, 200)
with allow_membership_writes():
self.membership.delete()
self.assertEqual(self.client.get("/api/v1/ledger-entries/").json(), {"error": {"code": "workspace_required"}})

@patch("api.auth_views.time.time", return_value=1_000_000)
def test_superuser_without_membership_cannot_read_foreign_or_unknown_workspace(self, mocked_time):
superuser = get_user_model().objects.create_superuser(
email="ledger-superuser-api@example.com", password="correct-horse-battery-staple"
)
self.client.force_login(superuser)
for workspace_public_id in (str(self.workspace.public_id), str(uuid4())):
session = self.client.session
session["api.auth_expires_at"] = 1_000_100
session["workspaces.active_workspace_public_id"] = workspace_public_id
session.save()

response = self.client.get("/api/v1/ledger-entries/")

self.assertEqual(response.status_code, 400)
self.assertEqual(response.json(), {"error": {"code": "workspace_required"}})

@patch("api.auth_views.time.time", return_value=1_000_000)
def test_returns_exact_public_projection_tenant_scoped_and_without_database_writes(self, mocked_time):
client = ClientModel.objects.create(
workspace=self.workspace, legal_name="Ledger Client", client_type="COMPANY",
tax_identifier="LEDGER-CLIENT", primary_contact_name="Ada Lovelace",
primary_contact_email="ada@example.com",
)
entry = self.make_entry(client=client)
foreign_workspace = Workspace.objects.create(name="Foreign Ledger", slug="foreign-ledger-api")
foreign_membership = Membership.objects.create(
workspace=foreign_workspace, user=self.other_user, role=Membership.Role.OWNER
)
foreign_category = create_category(
ActiveWorkspaceContext(foreign_workspace, foreign_membership), name="Foreign", description="Foreign",
default_deductible=False, monthly_budget=None, status=Category.Status.ACTIVE,
)
foreign_entry = self.make_entry(
context=ActiveWorkspaceContext(foreign_workspace, foreign_membership), category=foreign_category,
)
from ledger.models import LedgerEntry
before = list(LedgerEntry.objects.values_list("pk", "public_id", "created_at"))
self.authenticate(workspace=self.workspace)

response = self.client.get("/api/v1/ledger-entries/")

self.assertEqual(response.status_code, 200)
self.assertEqual(response["Cache-Control"], "no-store")
item = response.json()["data"]["items"][0]
self.assertEqual(set(item), {
"public_id", "direction", "source", "amount", "currency", "occurred_on", "description",
"category_name_snapshot", "category_deductible_snapshot", "client_public_id", "project_public_id", "created_at",
})
self.assertEqual(item["public_id"], str(entry.public_id))
self.assertEqual(item["amount"], "12.50")
self.assertEqual(item["currency"], "USD")
self.assertEqual(item["direction"], "EXPENSE")
self.assertEqual(item["source"], "MANUAL")
self.assertEqual(item["description"], "Taxi ride")
self.assertEqual(item["category_name_snapshot"], "Travel")
self.assertIs(item["category_deductible_snapshot"], True)
self.assertEqual(item["occurred_on"], "2024-01-01")
self.assertEqual(item["client_public_id"], str(client.public_id))
self.assertIsNone(item["project_public_id"])
for forbidden in ("pk", "idempotency_key", "fingerprint", "request_fingerprint", "created_by", "workspace", "category", "client", "project", "reversal_of"):
self.assertNotIn(forbidden, item)
self.assertNotIn(str(foreign_entry.public_id), {value["public_id"] for value in response.json()["data"]["items"]})
self.assertEqual(list(LedgerEntry.objects.values_list("pk", "public_id", "created_at")), before)

@patch("api.auth_views.time.time", return_value=1_000_000)
def test_rejects_every_query_shape_except_one_nonempty_cursor_and_returns_json_405(self, mocked_time):
self.authenticate(workspace=self.workspace)
for path in (
"/api/v1/ledger-entries/?workspace=ignored", "/api/v1/ledger-entries/?cursor=",
"/api/v1/ledger-entries/?cursor=one&cursor=two", "/api/v1/ledger-entries/?cursor=malformed",
):
response = self.client.get(path)
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json(), {"error": {"code": "invalid_request"}})
from api.ledger_views import CURSOR_SIGNER
unknown = CURSOR_SIGNER.sign("v1.unknown")
self.assertEqual(self.client.get(f"/api/v1/ledger-entries/?cursor={unknown}").status_code, 400)
response = self.client.post("/api/v1/ledger-entries/")
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json(), {"error": {"code": "method_not_allowed"}})
self.assertEqual(response["Allow"], "GET, HEAD, OPTIONS")

@patch("api.auth_views.time.time", return_value=1_000_000)
@patch("api.ledger_views.current_time", return_value=1_000_000)
def test_cursor_is_keyset_ordered_without_duplicates_or_misses_and_rejects_tampering_and_expiry(self, mocked_cursor_time, mocked_auth_time):
entries = [self.make_entry() for _ in range(26)]
self.authenticate(workspace=self.workspace)

first = self.client.get("/api/v1/ledger-entries/")
cursor = first.json()["data"]["next_cursor"]
second = self.client.get(f"/api/v1/ledger-entries/?cursor={cursor}")
items = first.json()["data"]["items"] + second.json()["data"]["items"]

self.assertEqual(len(first.json()["data"]["items"]), 25)
self.assertEqual(len(second.json()["data"]["items"]), 1)
self.assertEqual(len({item["public_id"] for item in items}), 26)
self.assertEqual([item["public_id"] for item in items], [str(entry.public_id) for entry in reversed(entries)])
self.assertEqual(self.client.get(f"/api/v1/ledger-entries/?cursor={cursor}x").status_code, 400)
mocked_cursor_time.return_value = 1_000_101
self.assertEqual(self.client.get(f"/api/v1/ledger-entries/?cursor={cursor}").status_code, 400)

@patch("api.auth_views.time.time", return_value=1_000_000)
@patch("api.ledger_views.current_time", return_value=1_000_000)
def test_cursor_is_bound_to_session_subject_workspace_membership_and_deadline(self, mocked_cursor_time, mocked_auth_time):
for _ in range(26):
self.make_entry()
self.authenticate(workspace=self.workspace)
cursor = self.client.get("/api/v1/ledger-entries/").json()["data"]["next_cursor"]
from api.ledger_views import CURSOR_SESSION_KEY, CURSOR_SIGNER

nonce = CURSOR_SIGNER.unsign(cursor).split(".", 1)[1]
original = {"subject": str(self.user.pk), "workspace": str(self.workspace.public_id), "membership": self.membership.pk, "deadline": 1_000_100}
for field, value in (("subject", "foreign"), ("workspace", str(uuid4())), ("membership", 999), ("deadline", 999)):
session = self.client.session
session[CURSOR_SESSION_KEY][nonce][field] = value
session.save()
self.assertEqual(self.client.get(f"/api/v1/ledger-entries/?cursor={cursor}").status_code, 400)
session = self.client.session
session[CURSOR_SESSION_KEY][nonce][field] = original[field]
session.save()

other_client = Client()
other_client.force_login(self.user)
session = other_client.session
session["api.auth_expires_at"] = 1_000_100
session["workspaces.active_workspace_public_id"] = str(self.workspace.public_id)
session.save()
response = other_client.get(f"/api/v1/ledger-entries/?cursor={cursor}")
self.assertEqual(response.status_code, 400)
self.assertEqual(response.json(), {"error": {"code": "invalid_request"}})


class LedgerEntryCursorStorageTests(TestCase):
@patch("api.ledger_views.current_time", return_value=100)
def test_cursor_store_prunes_expired_records_and_caps_live_entries(self, mocked_time):
from api.ledger_views import AUTH_EXPIRY_SESSION_KEY, CURSOR_MAX_ENTRIES, CURSOR_SESSION_KEY, _new_cursor

request = SimpleNamespace(user=SimpleNamespace(pk=7), session={AUTH_EXPIRY_SESSION_KEY: 1_000})
context = SimpleNamespace(workspace=SimpleNamespace(public_id="workspace"), membership=SimpleNamespace(pk=3))
request.session[CURSOR_SESSION_KEY] = {"expired": {"deadline": 99}, **{str(index): {"deadline": 1_000} for index in range(CURSOR_MAX_ENTRIES)}}
_new_cursor(request, context, {"occurred_on": date(2024, 1, 1), "pk": 999})
cursors = request.session[CURSOR_SESSION_KEY]
self.assertEqual(len(cursors), CURSOR_MAX_ENTRIES)
self.assertNotIn("expired", cursors)
self.assertNotIn("0", cursors)
4 changes: 3 additions & 1 deletion backend/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
from .auth_views import SessionLoginView, SessionLogoutView, SessionView
from .client_views import ClientListView
from .category_views import CategoryListView
from .invoice_views import InvoiceListView
from .fiscal_views import FiscalConfigurationView
from .invoice_views import InvoiceListView
from .ledger_views import LedgerEntryListView
from .notification_views import NotificationListView
from .payment_views import PaymentListView
from .proposal_views import ProposalListView
Expand All @@ -22,6 +23,7 @@
path("workspaces/active/", ActiveWorkspaceSelectionView.as_view(), name="workspace-active"),
path("notifications/", NotificationListView.as_view(), name="notification-list"),
path("payments/", PaymentListView.as_view(), name="payment-list"),
path("ledger-entries/", LedgerEntryListView.as_view(), name="ledger-entry-list"),
path("clients/", ClientListView.as_view(), name="client-list"),
path("categories/", CategoryListView.as_view(), name="category-list"),
path("services/", ServiceListView.as_view(), name="service-list"),
Expand Down