From 51e1827f5a74d0e4b2598ee2f78bf95c87c4c55e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Iwo=20Strzebo=C5=84ski?= Date: Tue, 20 Oct 2026 00:44:35 +0000 Subject: [PATCH] Stop storing printers' access codes `printers.access_code` held a printer's LAN password - the same value Bambu uses for MQTT and as the FTPS password. It was writable through `POST /printers`, excluded from `printerProjection` so it could not leave the server, and **never read by any backend code path**. A secret at rest in the cloud, serving no purpose. The credential belongs on the proxy, which is the only thing that uses it. Under the three-tier architecture it goes FE -> API -> proxy, with the API relaying it rather than persisting it. Considered and rejected, deliberately: keeping the column so the console could show an operator their printer's access code. Useful, but not worth the risk. Removing the column is what makes that rejection real - a stored secret is one a future route can leak, and the projection was the only thing standing between this column and a response. `PRINTER_WRITABLE` and the two schemas derived from it move to `db/validators.ts`, which already declares itself the single place these shapes are defined. The reason is testability: a route imports `db/index.ts` and so needs a live `DATABASE_URL`, and "what may a client set" is exactly the kind of thing worth asserting with no database in sight. Four tests pin the decision so it is enforced rather than remembered, because a column is easy to re-add and the symptom would be silent. One of them records a hole rather than a fix: `metadata` is a free-form jsonb column that is both writable and in the public projection, so a well-meaning client can still stash a credential there and have it stored and served back. Removing the dedicated column narrowed the door; it did not close it. The e2e script still submits `accessCode: "SECRET123"` on purpose. The field is now unknown to the create schema, the schema is open, and Elysia cleans it away rather than rejecting the request - so the canary proves against a real server that a client which tries to hand the cloud a credential has it dropped and never echoed. That is runtime behaviour the unit test can only establish at the schema level. Migration 0003 is a single `DROP COLUMN IF EXISTS`, applied locally. There is no production, staging or dev environment and nothing was writing the column, so there is no data to preserve and no consumer to break. --- drizzle/0003_colorful_electro.sql | 1 + drizzle/meta/0003_snapshot.json | 709 ++++++++++++++++++++++++++++++ drizzle/meta/_journal.json | 7 + scripts/e2e-authz.sh | 11 +- src/db/projections.ts | 20 +- src/db/schema/printers.ts | 1 - src/db/validators.ts | 35 ++ src/routes/printers.ts | 36 +- tests/authz.test.ts | 59 +++ 9 files changed, 837 insertions(+), 42 deletions(-) create mode 100644 drizzle/0003_colorful_electro.sql create mode 100644 drizzle/meta/0003_snapshot.json diff --git a/drizzle/0003_colorful_electro.sql b/drizzle/0003_colorful_electro.sql new file mode 100644 index 0000000..827c0b1 --- /dev/null +++ b/drizzle/0003_colorful_electro.sql @@ -0,0 +1 @@ +ALTER TABLE "printers" DROP COLUMN IF EXISTS "access_code"; \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..23e5043 --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,709 @@ +{ + "id": "e4751490-d3cf-44ca-8807-f7dba03415ea", + "prevId": "95497aac-876c-40e4-9683-f7a177fa5177", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.org_members": { + "name": "org_members", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "org_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_members_user_id_idx": { + "name": "org_members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "org_members_org_id_organisations_id_fk": { + "name": "org_members_org_id_organisations_id_fk", + "tableFrom": "org_members", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "org_members_org_id_user_id_pk": { + "name": "org_members_org_id_user_id_pk", + "columns": [ + "org_id", + "user_id" + ] + } + }, + "uniqueConstraints": {} + }, + "public.organisations": { + "name": "organisations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.farms": { + "name": "farms", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "org_id": { + "name": "org_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "edge_proxy_token": { + "name": "edge_proxy_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "farms_org_id_idx": { + "name": "farms_org_id_idx", + "columns": [ + { + "expression": "org_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "farms_org_id_organisations_id_fk": { + "name": "farms_org_id_organisations_id_fk", + "tableFrom": "farms", + "tableTo": "organisations", + "columnsFrom": [ + "org_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.printers": { + "name": "printers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vendor": { + "name": "vendor", + "type": "printer_vendor", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "printer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "lan_address": { + "name": "lan_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "serial_number": { + "name": "serial_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "gateway_printer_id": { + "name": "gateway_printer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "nozzle_temp_c": { + "name": "nozzle_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "nozzle_target_temp_c": { + "name": "nozzle_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_temp_c": { + "name": "bed_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "bed_target_temp_c": { + "name": "bed_target_temp_c", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "progress_percent": { + "name": "progress_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_layer": { + "name": "current_layer", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "total_layers": { + "name": "total_layers", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "remaining_minutes": { + "name": "remaining_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "current_job_name": { + "name": "current_job_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "active_alerts": { + "name": "active_alerts", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "camera_stream": { + "name": "camera_stream", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_state": { + "name": "vendor_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vendor_error_code": { + "name": "vendor_error_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "printers_farm_gateway_printer_id_idx": { + "name": "printers_farm_gateway_printer_id_idx", + "columns": [ + { + "expression": "farm_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "gateway_printer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "printers_farm_id_farms_id_fk": { + "name": "printers_farm_id_farms_id_fk", + "tableFrom": "printers", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.print_jobs": { + "name": "print_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "farm_id": { + "name": "farm_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "print_job_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "progress_pct": { + "name": "progress_pct", + "type": "real", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "estimated_time_s": { + "name": "estimated_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "elapsed_time_s": { + "name": "elapsed_time_s", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "submitted_by": { + "name": "submitted_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "print_jobs_farm_id_farms_id_fk": { + "name": "print_jobs_farm_id_farms_id_fk", + "tableFrom": "print_jobs", + "tableTo": "farms", + "columnsFrom": [ + "farm_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "print_jobs_printer_id_printers_id_fk": { + "name": "print_jobs_printer_id_printers_id_fk", + "tableFrom": "print_jobs", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "public.frame_embeddings": { + "name": "frame_embeddings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "print_job_id": { + "name": "print_job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "printer_id": { + "name": "printer_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "r2_object_key": { + "name": "r2_object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(512)", + "primaryKey": false, + "notNull": true + }, + "captured_at": { + "name": "captured_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "frame_embeddings_embedding_ivfflat_idx": { + "name": "frame_embeddings_embedding_ivfflat_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "ivfflat", + "with": { + "lists": 100 + } + } + }, + "foreignKeys": { + "frame_embeddings_print_job_id_print_jobs_id_fk": { + "name": "frame_embeddings_print_job_id_print_jobs_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "print_jobs", + "columnsFrom": [ + "print_job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frame_embeddings_printer_id_printers_id_fk": { + "name": "frame_embeddings_printer_id_printers_id_fk", + "tableFrom": "frame_embeddings", + "tableTo": "printers", + "columnsFrom": [ + "printer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": { + "public.org_role": { + "name": "org_role", + "schema": "public", + "values": [ + "viewer", + "operator", + "owner" + ] + }, + "public.printer_status": { + "name": "printer_status", + "schema": "public", + "values": [ + "offline", + "idle", + "printing", + "paused", + "error", + "maintenance" + ] + }, + "public.printer_vendor": { + "name": "printer_vendor", + "schema": "public", + "values": [ + "bambu", + "prusa", + "klipper" + ] + }, + "public.print_job_status": { + "name": "print_job_status", + "schema": "public", + "values": [ + "queued", + "uploading", + "printing", + "paused", + "completed", + "failed", + "cancelled" + ] + } + }, + "schemas": {}, + "sequences": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 238d6e9..10968dc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1792105504827, "tag": "0002_lucky_stepford_cuckoos", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1792399397819, + "tag": "0003_colorful_electro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/scripts/e2e-authz.sh b/scripts/e2e-authz.sh index f33e06d..0358e3e 100755 --- a/scripts/e2e-authz.sh +++ b/scripts/e2e-authz.sh @@ -186,12 +186,19 @@ FARM_A="$(json 'd["id"]')" body_lacks edgeProxyToken ok "farm A = $FARM_A, and the create response carries no proxy token" +# `accessCode` is deliberately still sent, even though the column was removed +# (2026-10-19) and the field is now unknown to the create schema. That is the +# point: a client that believes it can hand the cloud a printer's LAN password +# must have it dropped and never see it again. The schema is open, so the +# field validates and Elysia cleans it away rather than rejecting the request +# - which is behaviour worth proving against a real server, not just against +# the schema (see the credentials block in tests/authz.test.ts). expect 201 POST /printers "$OWNER_A" \ "{\"farmId\":\"$FARM_A\",\"name\":\"e2e printer A\",\"vendor\":\"bambu\",\"accessCode\":\"SECRET123\",\"serialNumber\":\"SN-SECRET\"}" \ - -- "owner_a adds printer A with an access code" + -- "owner_a adds printer A, trying to set an access code" PRINTER_A="$(json 'd["id"]')" body_lacks SECRET123 -ok "printer A = $PRINTER_A, and the create response carries no access code" +ok "printer A = $PRINTER_A, and the submitted access code was dropped, not echoed" echo "==> building org B (via the API, as the outsider)" expect 201 POST /orgs "$OUTSIDER" '{"name":"e2e org B"}' -- "outsider creates their own org B" diff --git a/src/db/projections.ts b/src/db/projections.ts index 8e3c1a4..dcb249f 100644 --- a/src/db/projections.ts +++ b/src/db/projections.ts @@ -3,19 +3,23 @@ import { farms, printers } from "./schema"; /** * Which columns each table is allowed to put in an API response. * - * These exist because two tables hold credentials in ordinary-looking - * columns, and `select *` handed them out to unauthenticated callers: + * These exist because `farms` holds a credential in an ordinary-looking + * column, and `select *` handed it out to unauthenticated callers: * * - `farms.edge_proxy_token` is what `POST /auth/edge-token` trades for an * edge JWT. With it and a farm id, anyone can connect to /ws/edge/v1 as * that farm's gateway and drive its printers. - * - `printers.access_code` is the printer's own LAN password - the same value - * Bambu uses for MQTT and as the FTPS password. * - * Neither is needed by a client that lists or displays these records, so - * neither is listed below. Opt-in projections rather than a deny-list on - * purpose: a column added to a table later is excluded until somebody decides - * it should be public, which is the safe direction in which to be wrong. + * A client that lists or displays these records does not need it, so it is + * not listed below. + * + * A printer's own LAN password (Bambu's access code, also its FTPS password) + * is deliberately NOT stored by the backend at all: it belongs on the proxy, + * the only thing that uses it. + * + * Opt-in projections rather than a deny-list on purpose: a column added to a + * table later is excluded until somebody decides it should be public, which + * is the safe direction in which to be wrong. * * This is defence in depth, NOT authorization. It narrows what a response can * contain and says nothing about who may ask - the routes are still diff --git a/src/db/schema/printers.ts b/src/db/schema/printers.ts index e38717b..57f5e0a 100644 --- a/src/db/schema/printers.ts +++ b/src/db/schema/printers.ts @@ -23,7 +23,6 @@ export const printers = pgTable( status: printerStatusEnum("status").notNull().default("offline"), lanAddress: text("lan_address"), serialNumber: text("serial_number"), - accessCode: text("access_code"), /** * The id this printer has in its gateway's own `printers.toml`, e.g. diff --git a/src/db/validators.ts b/src/db/validators.ts index 925c006..5e9daa9 100644 --- a/src/db/validators.ts +++ b/src/db/validators.ts @@ -23,6 +23,41 @@ export const PrinterInsertSchema = createInsertSchema(printers, { id: t.Optional(t.String({ format: "uuid" })), metadata: JsonObject, }); + +/** + * The columns a client may set on a printer, derived from the insert schema + * above so a renamed column still fails to compile. + * + * Everything left out is owned by the gateway, not by the API: `status`, the + * temperatures, layer counts, progress, `vendorState`, `lastSeenAt` and the + * rest are written only by `persistTelemetry` in routes/ws/edge.ts. A client + * that could POST `status: "idle"` could make the scheduler send work to a + * printer that is mid-print. + * + * Here rather than in `routes/printers.ts` so it can be asserted on without + * importing a route - a route pulls in `db/index.ts` and therefore needs a + * live `DATABASE_URL`, and what is writable is exactly the kind of thing + * worth testing with no database in sight. See the credentials block in + * `tests/authz.test.ts`. + */ +export const PRINTER_WRITABLE = [ + "farmId", + "name", + "vendor", + "lanAddress", + "serialNumber", + "gatewayPrinterId", + "metadata", +] as const; + +export const PrinterCreateSchema = t.Pick(PrinterInsertSchema, PRINTER_WRITABLE); + +/** + * `farmId` omitted: moving a printer to another farm would move its print + * history across an authorization boundary, so it is not a field on an + * update. + */ +export const PrinterUpdateSchema = t.Partial(t.Omit(PrinterCreateSchema, ["farmId"])); export const PrinterSelectSchema = createSelectSchema(printers); export const PrintJobInsertSchema = createInsertSchema(printJobs, { diff --git a/src/routes/printers.ts b/src/routes/printers.ts index 0c55a31..27a5035 100644 --- a/src/routes/printers.ts +++ b/src/routes/printers.ts @@ -4,7 +4,7 @@ import { NotFoundError } from "@continuum/ts-core"; import { PrinterCommandSchema } from "@continuum/ts-types"; import { db } from "../db"; import { printers } from "../db/schema"; -import { PrinterInsertSchema } from "../db/validators"; +import { PrinterCreateSchema, PrinterUpdateSchema } from "../db/validators"; import { printerProjection } from "../db/projections"; import { requireFarm, requirePrinter, requireUser, visiblePrinters } from "../auth"; import { PaginationQuery, paginate } from "./pagination"; @@ -14,40 +14,14 @@ import { isGatewayOnline, negotiateCameraStream, sendPrinterCommand } from "./ws const IdParams = t.Object({ id: t.String({ format: "uuid" }) }); -/** - * The columns a client may set, derived from the table's insert schema so a - * renamed column still fails to compile. - * - * Everything left out is owned by the gateway, not by the API: `status`, - * the temperatures, layer counts, progress, `vendorState`, `lastSeenAt` and - * the rest are written only by `persistTelemetry` in routes/ws/edge.ts. A - * client that could POST `status: "idle"` could make the scheduler send work - * to a printer that is mid-print. - */ -const PRINTER_WRITABLE = [ - "farmId", - "name", - "vendor", - "lanAddress", - "serialNumber", - "accessCode", - "gatewayPrinterId", - "metadata", -] as const; - -const PrinterCreateSchema = t.Pick(PrinterInsertSchema, PRINTER_WRITABLE); -// `farmId` omitted: moving a printer to another farm would move its print -// history across an authorization boundary, so it is not a field on an update. -const PrinterUpdateSchema = t.Partial( - t.Omit(PrinterCreateSchema, ["farmId"]), -); export const printerRoutes = new Elysia({ prefix: "/printers" }) .use(requireUser) // Scoped through farm -> org membership, and a projection rather than - // `select *`: `printers.accessCode` is the printer's LAN password (Bambu - // uses it for MQTT and as the FTPS password) and must not leave the server. - // See src/db/projections.ts. + // `select *`: the projection is an allow-list, so a column added later stays + // out of responses until someone decides it is public. (Printers hold no + // credential today - a printer's LAN access code lives on the proxy, not + // here.) See src/db/projections.ts. .get( "/", async ({ user, query }) => { diff --git a/tests/authz.test.ts b/tests/authz.test.ts index 3e755ef..ca64b0d 100644 --- a/tests/authz.test.ts +++ b/tests/authz.test.ts @@ -3,6 +3,10 @@ import { Elysia } from "elysia"; import { toErrorResponse } from "@continuum/ts-core"; import { roleSatisfies, ORG_ROLES } from "../src/auth/roles"; import { startDevAuthIssuer } from "../scripts/dev-auth"; +import { Value } from "@sinclair/typebox/value"; +import { printers } from "../src/db/schema"; +import { farmProjection, printerProjection } from "../src/db/projections"; +import { PrinterCreateSchema } from "../src/db/validators"; const USER = "11111111-1111-1111-1111-111111111111"; @@ -249,3 +253,58 @@ describe("object storage configuration", () => { } }); }); + +/** + * The credentials this backend is not allowed to hold or hand out. + * + * `printers.accessCode` used to exist: writable through `POST /printers`, + * excluded from the projection, and never read by any backend code path - a + * printer's LAN password at rest in the cloud for no purpose. It was removed + * (2026-10-19) because the credential belongs on the proxy, the only thing + * that uses it, and the alternative of surfacing it in the UI was considered + * and rejected: useful, but not worth the risk. + * + * These tests exist so that decision is enforced rather than remembered. A + * column is easy to re-add, and the symptom would be silent. + */ +describe("credentials the backend must not hold", () => { + test("a printer has no access-code column to store one in", () => { + // Against the Drizzle table rather than live SQL, so it fails in CI with + // no database - the point is the declaration, not this machine's schema. + expect(Object.keys(printers)).not.toContain("accessCode"); + }); + + test("a client that sends one anyway has it dropped, not stored", () => { + // The create schema is open, so a stray field validates. Elysia cleans + // the body against the schema, which is what actually discards it - so + // the honest description is "ignored", not "rejected". Worth pinning: + // if the field ever became retained, nothing else here would notice. + const body = { + farmId: "3f2b1c5e-0000-4000-8000-000000000000", + name: "P1P", + vendor: "bambu" as const, + accessCode: "SECRET123", + }; + const cleaned = Value.Clean(PrinterCreateSchema, structuredClone(body)) as Record; + expect(cleaned).not.toHaveProperty("accessCode"); + expect(cleaned.name).toBe("P1P"); + }); + + test("the farm's edge proxy token is still kept out of responses", () => { + // The credential that does remain. `POST /auth/edge-token` trades it for + // an edge JWT, so with it and a farm id anyone can connect as that + // farm's gateway and drive its printers. + expect(Object.keys(farmProjection.columns)).not.toContain("edgeProxyToken"); + }); + + test("metadata is the remaining way a secret could reach the cloud", () => { + // Not a bug to fix here, but a hole to know about: `metadata` is a + // free-form jsonb column that is both writable and in the public + // projection, so a well-meaning client could stash an access code in it + // and the API would store it and serve it back. Removing the dedicated + // column narrowed the door; it did not close it. If this assertion ever + // fails because `metadata` left the projection, that is an improvement - + // delete the test. + expect(Object.keys(printerProjection.columns)).toContain("metadata"); + }); +});