feat(api): record which host holds a Steam token for whom

A host that signs a person into Steam ends up holding a refresh token. The
control plane needs to know that happened — to show it, and so a host that
lost its disk can find out what it is expected to hold — but it must not know
the credential, because the token is bound to the address that obtained it and
a copy anywhere else is the account-theft signal Steam watches for.

So `steam_enrolment` stores the outcome and has no token column, no encrypted
token column, and no column that could hold one later. The safeguard is that
the credential is never sent here at all; a nullable column would be the first
step in undoing it, so a test asserts the column list exactly and fails if one
appears. Three machine-authenticated routes go with it: report a completed
sign-in, report that Steam refused the token, and list what this host should
have. All three take the host from its own credentials, so a box can neither
report onto nor read another box's hardware. Their bodies are strict, so a
host that sends a token is told it is wrong rather than quietly believed —
which also keeps the value out of the request log.

The Steam id is deliberately not unique. One account signed in on two hosts is
two rows and two tokens, and a unique index there would look like hygiene while
refusing somebody their second box.

There is no `pending` state: a sign-in challenge lives about two minutes inside
one process, and nothing outside it needs to know it exists. Nothing revokes
yet, and `last_ok_at` has no writer — a successful logon happens where there is
no credential to report it with — so the column exists with the shape it will
need and stays null rather than being filled with the nearest event that was
easy to observe.
This commit is contained in:
Wanjohi
2026-09-06 13:27:51 +03:00
parent 7f7e39de60
commit 6429ec4ff7
12 changed files with 3839 additions and 2 deletions

View File

@@ -0,0 +1,47 @@
-- That a host holds a Steam refresh token for a user — and never the token.
--
-- The auth session begins on the machine that will use the credential, so the
-- token is written on that host, encrypted, under that host's own account, and
-- it never travels back. What travels back is the outcome, and this table is
-- where the outcome is kept. ref(d-0004)
--
-- **There is no token column and there must never be one**, including a
-- nullable "encrypted token" that looks harmless while empty. The protection
-- here is not that the column is guarded; it is that the credential is never
-- sent to this database at all, and a column able to hold one is the first step
-- in undoing that. The same applies to the challenge URL and client id the
-- sign-in flow uses: they live for about two minutes inside one process and
-- nothing outside it needs them.
--
-- `steam_id` is not unique, on purpose. One Steam account signed in on two
-- hosts is two rows and two tokens, because each token is bound to the address
-- that asked for it — that binding is the anti-theft signal, and sharing one
-- token between hosts is the thing it fires on. A unique index here would read
-- as hygiene and would refuse a person their second box.
--
-- The key is the pair. An enrolment is a fact about this user on this host and
-- there is exactly one such fact, so the row carries no surrogate id. It also
-- carries no `time_deleted`: the three states are the lifecycle, and the row
-- itself only goes away when the machine or the user does, which the foreign
-- keys already do.
--
-- `last_ok_at` has no writer yet. A successful logon happens inside the
-- workload, which holds no control-plane credential, so the report has to come
-- back out through the host and nothing carries it today. The column exists
-- with the shape it will need and stays null rather than being filled with the
-- nearest event that was easy to observe.
CREATE TYPE "public"."steam_enrolment_state" AS ENUM('enrolled', 'stale', 'revoked');--> statement-breakpoint
CREATE TABLE "steam_enrolment" (
"machine_id" char(30) NOT NULL,
"user_id" char(30) NOT NULL,
"steam_id" text NOT NULL,
"state" "steam_enrolment_state" NOT NULL,
"enrolled_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_ok_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
CONSTRAINT "steam_enrolment_machine_id_user_id_pk" PRIMARY KEY("machine_id","user_id")
);
--> statement-breakpoint
ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

View File

@@ -85,6 +85,13 @@
"when": 1788607804606,
"tag": "0011_auth_state_in_postgres",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788690115352,
"tag": "0012_steam_enrolment_without_a_token",
"breakpoints": true
}
]
}