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
405 changes: 405 additions & 0 deletions READ_ONLY_PRIVATE_MAPS.md

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions migrations/1785888000000_map_share.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { sql } from "kysely";
import type { Kysely } from "kysely";

/**
* Read-only private map sharing: one share per map, addressed by an
* unguessable token, optionally protected by a password.
* `password_updated_at` invalidates viewer grants issued before a
* password change.
*/
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable("mapShare")
.addColumn("id", "uuid", (col) =>
col.primaryKey().defaultTo(sql`gen_random_uuid()`),
)
.addColumn("mapId", "uuid", (col) => col.notNull().unique())
.addColumn("token", "text", (col) => col.notNull().unique())
.addColumn("enabled", "boolean", (col) => col.notNull().defaultTo(true))
.addColumn("passwordHash", "text")
.addColumn("passwordUpdatedAt", "timestamp")
.addColumn("createdAt", "timestamp", (col) =>
col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`),
)
.addForeignKeyConstraint(
"mapShareMapIdFKey",
["mapId"],
"map",
["id"],
(cb) => cb.onDelete("cascade").onUpdate("cascade"),
)
.execute();
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable("mapShare").execute();
}
8 changes: 7 additions & 1 deletion src/app/api/data-sources/[id]/markers/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { getServerSession } from "@/auth";
import { getShareGrants } from "@/auth/shareGrants";
import { MARKER_MATCHED_COLUMN } from "@/constants";
import { streamDataRecordsByDataSource } from "@/server/repositories/DataRecord";
import { findDataSourceById } from "@/server/repositories/DataSource";
Expand Down Expand Up @@ -35,7 +36,12 @@ export async function GET(
return new NextResponse("Not found", { status: 404 });
}

const canRead = await checkAccess(dataSource, currentUser?.id);
const shareGrants = await getShareGrants();
const canRead = await checkAccess({
dataSource,
userId: currentUser?.id,
shareGrants,
});
if (!canRead) {
return new NextResponse("Not found", { status: 404 });
}
Expand Down
69 changes: 69 additions & 0 deletions src/auth/shareGrants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { SignJWT, jwtVerify } from "jose";
import { cookies } from "next/headers";
import { SHARE_GRANT_LIFETIME_SECONDS } from "@/constants";
import type { ShareGrant } from "@/authTypes";

export const SHARE_GRANTS_COOKIE = "SharedMaps";

// Cap the cookie size; adding a grant beyond the cap drops the oldest
const MAX_SHARE_GRANTS = 10;

/**
* Read and verify the share-grant cookie. Returns [] for missing, invalid
* or expired cookies. Grants returned here are only *candidates*: callers
* must validate them against the live mapShare row (see
* `findValidShareGrantForMap` in server/utils/auth.ts).
*/
export async function getShareGrants(): Promise<ShareGrant[]> {
const cookieStore = await cookies();
const cookie = cookieStore.get(SHARE_GRANTS_COOKIE);
if (!cookie?.value) {
return [];
}
try {
const secret = new TextEncoder().encode(process.env.JWT_SECRET || "");
const { payload } = await jwtVerify<{ grants: ShareGrant[] }>(
cookie.value,
secret,
);
return Array.isArray(payload.grants) ? payload.grants : [];
} catch {
// Don't bother logging invalid JWTs
return [];
}
}

/**
* Mint a grant for a share and store it in the signed cookie, replacing any
* previous grant for the same share. Must be called from a route handler or
* server action: Next.js forbids setting cookies during server component
* render.
*/
export async function addShareGrant({
shareId,
mapId,
}: {
shareId: string;
mapId: string;
}): Promise<void> {
const existing = await getShareGrants();
const nowSeconds = Math.floor(Date.now() / 1000);
const grants: ShareGrant[] = [
{ shareId, mapId, iat: nowSeconds },
...existing.filter((grant) => grant.shareId !== shareId),
].slice(0, MAX_SHARE_GRANTS);

const secret = new TextEncoder().encode(process.env.JWT_SECRET || "");
const token = await new SignJWT({ grants })
.setProtectedHeader({ alg: "HS256" })
.setExpirationTime(nowSeconds + SHARE_GRANT_LIFETIME_SECONDS)
.sign(secret);

const cookieStore = await cookies();
cookieStore.set(SHARE_GRANTS_COOKIE, token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
});
}
14 changes: 14 additions & 0 deletions src/authTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,17 @@ export interface ServerSession {
jwt: string | null;
currentUser: CurrentUser | null;
}

/**
* Grants an anonymous visitor read access to a shared map. Minted after the
* visitor proves possession of the share link (and its password, if set);
* carried in a signed cookie. Never sufficient on its own: every check
* re-validates against the live mapShare row.
*/
export interface ShareGrant {
shareId: string;
mapId: string;
// Unix seconds when the grant was minted. Grants minted before the
// share's passwordUpdatedAt fail validation while a password is set.
iat: number;
}
3 changes: 3 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ export const GE_DATA_SOURCE_NAME = "2024 General Election Results";

export const JWT_LIFETIME_SECONDS = 24 * 60 * 60;

// Lifetime of a read-only map share grant (see ShareGrant in authTypes.ts)
export const SHARE_GRANT_LIFETIME_SECONDS = 7 * 24 * 60 * 60;

// Different database derived column name because underscores get mangled by camelCase translation
export const MARKER_MATCHED_COLUMN = "mappedMatched";

Expand Down
15 changes: 15 additions & 0 deletions src/models/MapShare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import z from "zod";

// `passwordHash` is stripped from tRPC responses by the
// `hasPasswordHashSerializer` superjson custom serializer.
export const mapShareSchema = z.object({
id: z.string(),
mapId: z.string(),
token: z.string(),
enabled: z.boolean(),
passwordHash: z.string().nullable(),
passwordUpdatedAt: z.date().nullable(),
createdAt: z.date(),
});

export type MapShare = z.infer<typeof mapShareSchema>;
9 changes: 9 additions & 0 deletions src/server/models/MapShare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { MapShare } from "@/models/MapShare";
import type { ColumnType, Generated, Insertable, Updateable } from "kysely";

export type MapShareTable = MapShare & {
id: Generated<string>;
createdAt: ColumnType<Date, string | undefined, never>;
};
export type NewMapShare = Insertable<MapShareTable>;
export type MapShareUpdate = Updateable<MapShareTable>;
142 changes: 142 additions & 0 deletions src/server/repositories/MapShare.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { randomBytes } from "crypto";

import { db } from "@/server/services/database";
import type { MapConfig } from "@/models/Map";
import type { MapViewConfig } from "@/models/MapView";
import type { TraversedJSONPathBuilder } from "kysely";

// 18 random bytes → 24-char base64url token (144 bits of entropy).
// The URL is itself a secret for passwordless shares.
const generateShareToken = () => randomBytes(18).toString("base64url");

export function findMapShareByToken(token: string) {
return db
.selectFrom("mapShare")
.where("token", "=", token)
.selectAll()
.executeTakeFirst();
}

export function findMapShareByMapId(mapId: string) {
return db
.selectFrom("mapShare")
.where("mapId", "=", mapId)
.selectAll()
.executeTakeFirst();
}

/**
* Enable sharing for a map. The first call creates the share with a fresh
* token; later calls re-enable it, keeping the existing token and password
* so a disabled link is restored rather than replaced.
*/
export function upsertMapShareForMap(mapId: string) {
return db
.insertInto("mapShare")
.values({ mapId, token: generateShareToken(), enabled: true })
.onConflict((oc) => oc.column("mapId").doUpdateSet({ enabled: true }))
.returningAll()
.executeTakeFirstOrThrow();
}

export function setMapShareEnabled({
mapId,
enabled,
}: {
mapId: string;
enabled: boolean;
}) {
return db
.updateTable("mapShare")
.set({ enabled })
.where("mapId", "=", mapId)
.returningAll()
.executeTakeFirst();
}

/**
* Set (or remove, with `passwordHash: null`) the share password hash.
* Hashing happens in the caller (see the mapShare router) to keep this
* module free of auth imports. Always bumps `passwordUpdatedAt` so viewer
* grants issued before the change stop validating.
*/
export function setMapSharePassword({
mapId,
passwordHash,
}: {
mapId: string;
passwordHash: string | null;
}) {
return db
.updateTable("mapShare")
.set({ passwordHash, passwordUpdatedAt: new Date() })
.where("mapId", "=", mapId)
.returningAll()
.executeTakeFirst();
}

/**
* Find an enabled share, among the given maps, whose map visualises this
* data source — via the map's membersDataSourceId or markerDataSourceIds,
* or any of the map's views' areaDataSourceId. Used to decide whether a
* share-grant holder may read the data source's records; mirrors
* `findPublishedPublicMapByDataSourceId`, but across all views of the map.
*/
export function findMapShareVisualisingDataSource({
dataSourceId,
mapIds,
}: {
dataSourceId: string;
mapIds: string[];
}) {
if (mapIds.length === 0) {
return Promise.resolve(undefined);
}
return db
.selectFrom("mapShare")
.innerJoin("map", "map.id", "mapShare.mapId")
.where("mapShare.mapId", "in", mapIds)
.where("mapShare.enabled", "=", true)
.where(({ eb, exists, ref, selectFrom }) =>
eb.or([
eb(
ref("map.config", "->>").key("membersDataSourceId"),
"=",
dataSourceId,
),
eb(
ref("map.config", "->").key(
"markerDataSourceIds",
) as TraversedJSONPathBuilder<MapConfig, string>,
"@>",
JSON.stringify([dataSourceId]),
),
exists(
selectFrom("mapView")
.select("mapView.id")
.whereRef("mapView.mapId", "=", "map.id")
.where(({ eb: viewEb, ref: viewRef }) =>
viewEb(
viewRef("mapView.config", "->>").key(
"areaDataSourceId",
) as TraversedJSONPathBuilder<MapViewConfig, string>,
"=",
dataSourceId,
),
),
),
]),
)
.selectAll("mapShare")
.executeTakeFirst();
}

/** Rotate the token, invalidating the previously shared URL. */
export function regenerateMapShareToken(mapId: string) {
return db
.updateTable("mapShare")
.set({ token: generateShareToken() })
.where("mapId", "=", mapId)
.returningAll()
.executeTakeFirst();
}
2 changes: 2 additions & 0 deletions src/server/services/database/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { InspectorDataSourceConfigTable } from "@/server/models/InspectorDa
import type { InvitationTable } from "@/server/models/Invitation";
import type { JobTable } from "@/server/models/Job";
import type { MapTable } from "@/server/models/Map";
import type { MapShareTable } from "@/server/models/MapShare";
import type { MapViewTable } from "@/server/models/MapView";
import type { OrganisationTable } from "@/server/models/Organisation";
import type { OrganisationUserTable } from "@/server/models/OrganisationUser";
Expand Down Expand Up @@ -68,6 +69,7 @@ export interface Database {
geocodeCache: GeocodeCacheTable;
invitation: InvitationTable;
map: MapTable;
mapShare: MapShareTable;
mapView: MapViewTable;
inspectorDataSourceConfig: InspectorDataSourceConfigTable;
organisation: OrganisationTable;
Expand Down
Loading
Loading