trails/apps/journal/app/lib/ownership.server.ts
Ullrich Schäfer 7a1dca378f
journal: branded ownership loading for routes and activities
Ownership was checked ad hoc: some handlers loaded-and-compared
ownerId, some lib mutators enforced it in WHERE clauses and silently
no-op'd for non-owners, and nothing tied the two together. Two real
authorization bugs hid in the gaps: linkActivityToRoute ignored its
ownerId parameter entirely (any logged-in user could relink any
activity), and createRouteFromActivity loaded any activity without an
ownership check (a non-owner could copy a private activity's GPX into
their own route). PUT /api/v1/routes/:id also returned ok:true for
non-owners without updating anything.

ownership.server.ts is now the single enforcement point:

- loadOwnedRoute / loadOwnedActivity (non-throwing, for callers with
  their own error vocabulary) and requireOwnedRoute /
  requireOwnedActivity (throwing data() 404/403 for web handlers; 404
  by default so guessed ids don't leak existence)
- the returned entities carry an Owned<> brand; mutators (updateRoute,
  deleteRoute, deleteActivity, updateActivityVisibility,
  linkActivityToRoute, createRouteFromActivity) now require an
  OwnedRef, so skipping the check is a compile error
- vouchOwnership is the explicit, greppable escape hatch for the one
  non-session authorization path (the Planner JWT callback)
- WHERE ownerId clauses stay in the mutators as defense in depth

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 02:35:00 +02:00

88 lines
3.3 KiB
TypeScript

// The single enforcement point for "does this user own this entity".
//
// Mutators in routes.server.ts / activities.server.ts take an `OwnedRef`
// — a branded {id, ownerId} that can only be produced here (or by the
// explicit `vouchOwnership` escape hatch). A handler that forgets the
// ownership check no longer compiles, instead of silently no-op'ing or
// leaking another user's data.
import { data } from "react-router";
import { getRoute } from "./routes.server.ts";
import { getActivity } from "./activities.server.ts";
declare const ownedBrand: unique symbol;
/** An entity verified to belong to the acting user (or an equivalent grant). */
export type Owned<T> = T & { readonly [ownedBrand]: true };
/** The minimal shape mutators need; any Owned entity satisfies it. */
export type OwnedRef = Owned<{ id: string; ownerId: string }>;
export type OwnershipFailure = "not_found" | "not_owner";
type LoadResult<T> = { ok: true; entity: Owned<T> } | { ok: false; reason: OwnershipFailure };
function check<T extends { ownerId: string | null }>(
entity: T | null,
userId: string,
): LoadResult<T & { ownerId: string }> {
if (!entity) return { ok: false, reason: "not_found" };
// ownerId === null covers remote-ingested content, which nobody owns locally.
if (entity.ownerId === null || entity.ownerId !== userId) {
return { ok: false, reason: "not_owner" };
}
return { ok: true, entity: entity as Owned<T & { ownerId: string }> };
}
/** Non-throwing core — for callers with their own error vocabulary
* (discriminated results, apiError JSON shapes). */
export async function loadOwnedRoute(routeId: string, userId: string) {
return check(await getRoute(routeId), userId);
}
export async function loadOwnedActivity(activityId: string, userId: string) {
return check(await getActivity(activityId), userId);
}
interface RequireOptions {
/**
* Status for the not-owner case. Defaults to 404 so a guessed id
* doesn't leak that the entity exists; pass 403 on surfaces where
* the entity is already known to the viewer (e.g. the edit page).
*/
notOwnerStatus?: 403 | 404;
}
function throwFor(reason: OwnershipFailure, label: string, opts?: RequireOptions): never {
if (reason === "not_owner" && (opts?.notOwnerStatus ?? 404) === 403) {
throw data({ error: "Not authorized" }, { status: 403 });
}
throw data({ error: `${label} not found` }, { status: 404 });
}
/** Throwing wrapper for web loaders/actions: 404 / 403 as `data()`. */
export async function requireOwnedRoute(routeId: string, userId: string, opts?: RequireOptions) {
const result = await loadOwnedRoute(routeId, userId);
if (!result.ok) throwFor(result.reason, "Route", opts);
return result.entity;
}
export async function requireOwnedActivity(
activityId: string,
userId: string,
opts?: RequireOptions,
) {
const result = await loadOwnedActivity(activityId, userId);
if (!result.ok) throwFor(result.reason, "Activity", opts);
return result.entity;
}
/**
* Escape hatch for callers whose authorization does not come from a
* session — today only the Planner JWT callback, where a single-use
* token scoped to the route stands in for the owner. Every use should
* be greppable and justified by a comment at the call site.
*/
export function vouchOwnership<T extends { id: string; ownerId: string }>(entity: T): Owned<T> {
return entity as Owned<T>;
}