mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 14:12:39 +02:00
feat(machine): an install command that adds a host to the fleet
An install token can now name an organisation instead of a team, and the host it registers belongs to that organisation's fleet. Only members of the organisation may mint one, since fleet hardware is rented to other people. A token for both is refused, by the route and by a check constraint, as it already is for a machine.
This commit is contained in:
@@ -175,7 +175,7 @@ export namespace MachineApi {
|
||||
tags: ['Machine'],
|
||||
summary: 'Issue an install token',
|
||||
description:
|
||||
'Mint a one-time token that registers one host to a team when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.',
|
||||
'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -201,11 +201,15 @@ export namespace MachineApi {
|
||||
z.object({
|
||||
teamId: z.string().optional().meta({
|
||||
description: 'Team the host will belong to. Defaults to the caller\u2019s personal team'
|
||||
}),
|
||||
organisationId: z.string().optional().meta({
|
||||
description:
|
||||
'Register the host to this organisation’s fleet instead of to a team. You must belong to the organisation'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { teamId } = c.req.valid('json');
|
||||
const { teamId, organisationId } = c.req.valid('json');
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
@@ -215,6 +219,30 @@ export namespace MachineApi {
|
||||
);
|
||||
}
|
||||
|
||||
if (organisationId) {
|
||||
if (teamId) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
'A host belongs to a team or to an organisation, not both',
|
||||
'organisationId'
|
||||
);
|
||||
}
|
||||
// Fleet hardware is rented to strangers, so only the
|
||||
// organisation's own people may add to it.
|
||||
if (!(await Organisation.isMember(Actor.userID, organisationId))) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'You do not belong to that organisation'
|
||||
);
|
||||
}
|
||||
const issued = await InstallToken.create({ organisationId, userId: Actor.userID });
|
||||
return c.json({
|
||||
data: { token: issued.token, expiresAt: issued.expiresAt.toISOString() }
|
||||
});
|
||||
}
|
||||
|
||||
// Same resolution and the same membership rule as `/register`: a
|
||||
// token is a deferred registration, so it may not reach a team the
|
||||
// caller could not register into directly.
|
||||
|
||||
@@ -61,6 +61,7 @@ afterAll(async () => {
|
||||
await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`;
|
||||
await sql`delete from box where user_id = any(${createdUserIds})`;
|
||||
await sql`delete from game where id = any(${createdGameIds})`;
|
||||
await sql`delete from install_token where created_by_user_id = any(${createdUserIds})`;
|
||||
await sql`delete from machine where owner_user_id = any(${createdUserIds})`;
|
||||
await sql`delete from organisation where id = any(${createdOrgIds})`;
|
||||
await sql`delete from "user" where id = any(${createdUserIds})`;
|
||||
@@ -206,3 +207,76 @@ describe('Billing a run on the fleet', () => {
|
||||
expect(closed[0]!.ended_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('An install token for the fleet', () => {
|
||||
async function member(domain: string, organisationId: string) {
|
||||
const userId = Identifier.ascending('user');
|
||||
await sql`insert into "user" (id, name, email, email_verified) values (${userId}, 'staff', ${'staff@' + domain}, true)`;
|
||||
createdUserIds.push(userId);
|
||||
const pat = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: userId,
|
||||
teamId: null,
|
||||
name: 'staff'
|
||||
});
|
||||
return {
|
||||
userId,
|
||||
organisationId,
|
||||
headers: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' }
|
||||
};
|
||||
}
|
||||
|
||||
async function org() {
|
||||
const id = Identifier.ascending('organisation');
|
||||
const domain = `${id.slice(-10).toLowerCase()}.example.test`;
|
||||
await sql`insert into organisation (id, name, slug, domain, domain_verified)
|
||||
values (${id}, 'Fleet', ${'fleet-' + id.slice(-10).toLowerCase()}, ${domain}, true)`;
|
||||
createdOrgIds.push(id);
|
||||
return { id, domain };
|
||||
}
|
||||
|
||||
test('a member mints one, and the host it registers belongs to the fleet', async () => {
|
||||
const o = await org();
|
||||
const staff = await member(o.domain, o.id);
|
||||
const minted = await app.request('/machine/install-token', {
|
||||
method: 'POST',
|
||||
headers: staff.headers,
|
||||
body: JSON.stringify({ organisationId: o.id })
|
||||
});
|
||||
expect(minted.status).toBe(200);
|
||||
const { token } = ((await minted.json()) as any).data;
|
||||
|
||||
const installed = await app.request('/machine/install', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token, label: 'fleet-host' })
|
||||
});
|
||||
expect(installed.status).toBe(200);
|
||||
const { machineId } = ((await installed.json()) as any).data;
|
||||
const machine = await Machine.fromID(machineId);
|
||||
expect(machine?.organisationId).toBe(o.id);
|
||||
expect(machine?.teamId).toBeNull();
|
||||
});
|
||||
|
||||
test('somebody outside the organisation is refused', async () => {
|
||||
const o = await org();
|
||||
const outsider = await person('fleet-outsider');
|
||||
const res = await app.request('/machine/install-token', {
|
||||
method: 'POST',
|
||||
headers: outsider.headers,
|
||||
body: JSON.stringify({ organisationId: o.id })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('naming a team and an organisation is a 400', async () => {
|
||||
const o = await org();
|
||||
const staff = await member(o.domain, o.id);
|
||||
const res = await app.request('/machine/install-token', {
|
||||
method: 'POST',
|
||||
headers: staff.headers,
|
||||
body: JSON.stringify({ organisationId: o.id, teamId: 'tem_whatever' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "install_token" ALTER COLUMN "team_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD COLUMN "organisation_id" char(30);--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_one_owner" CHECK (("install_token"."team_id" is null) != ("install_token"."organisation_id" is null));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -134,6 +134,13 @@
|
||||
"when": 1791354383696,
|
||||
"tag": "0018_free_weekend_trial",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1791355334692,
|
||||
"tag": "0019_install_token_for_a_fleet",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { OrganisationTable } from '../organisation/organisation.sql.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { MachineTable } from './machine.sql.js';
|
||||
|
||||
/**
|
||||
* A one-time credential that registers exactly one machine to one team.
|
||||
* A one-time credential that registers exactly one machine to one team, or to
|
||||
* one organisation's fleet — never both, by the same rule a machine has.
|
||||
*
|
||||
* It exists so that installing on a host is a command a person pastes, rather
|
||||
* than a user session copied onto a machine. It travels in that command, so it
|
||||
@@ -18,9 +21,10 @@ export const InstallTokenTable = pgTable(
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
organisationId: ulid('organisation_id').references(() => OrganisationTable.id, {
|
||||
onDelete: 'cascade'
|
||||
}),
|
||||
// Who asked for it. They become the machine's owner, as they would have
|
||||
// by registering it with their own session.
|
||||
createdByUserId: ulid('created_by_user_id')
|
||||
@@ -35,6 +39,7 @@ export const InstallTokenTable = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('install_token_hash_unique').on(t.tokenHash),
|
||||
index('install_token_team_idx').on(t.teamId)
|
||||
index('install_token_team_idx').on(t.teamId),
|
||||
check('install_token_one_owner', sql`(${t.teamId} is null) != (${t.organisationId} is null)`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -30,12 +30,14 @@ describe('Install tokens', () => {
|
||||
const registered = await InstallToken.redeem({ token, label: 'host' });
|
||||
expect(registered?.secret.startsWith('msk_')).toBe(true);
|
||||
|
||||
const rows = await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
|
||||
const rows =
|
||||
await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
|
||||
expect(rows[0]!.team_id).toBe(owner.teamId);
|
||||
expect(rows[0]!.owner_user_id).toBe(owner.userId);
|
||||
|
||||
// Only the digest is kept, and the row records what it produced.
|
||||
const tok = await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
|
||||
const tok =
|
||||
await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
|
||||
expect(tok[0]!.token_hash).not.toBe(token);
|
||||
expect(tok[0]!.machine_id).toBe(registered!.machineId);
|
||||
});
|
||||
|
||||
@@ -31,25 +31,40 @@ export namespace InstallToken {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Issue a token for `teamId`. The caller has already checked membership. */
|
||||
export const create = fn(z.object({ teamId: z.string(), userId: z.string() }), async (input) => {
|
||||
const token = generate();
|
||||
const tokenHash = await digest(token);
|
||||
const expiresAt = await Database.use(async (tx) =>
|
||||
tx
|
||||
.insert(InstallTokenTable)
|
||||
.values({
|
||||
id: Identifier.ascending('installToken'),
|
||||
teamId: input.teamId,
|
||||
createdByUserId: input.userId,
|
||||
tokenHash,
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
|
||||
})
|
||||
.returning({ expiresAt: InstallTokenTable.expiresAt })
|
||||
.then((rows) => rows[0]!.expiresAt)
|
||||
);
|
||||
return { token, expiresAt };
|
||||
});
|
||||
/**
|
||||
* Issue a token for a team, or for an organisation's fleet. The caller has
|
||||
* already checked membership of whichever it names.
|
||||
*/
|
||||
export const create = fn(
|
||||
z
|
||||
.object({
|
||||
teamId: z.string().optional(),
|
||||
organisationId: z.string().optional(),
|
||||
userId: z.string()
|
||||
})
|
||||
.refine((v) => !v.teamId !== !v.organisationId, {
|
||||
message: 'A token is for a team or for an organisation, and not both'
|
||||
}),
|
||||
async (input) => {
|
||||
const token = generate();
|
||||
const tokenHash = await digest(token);
|
||||
const expiresAt = await Database.use(async (tx) =>
|
||||
tx
|
||||
.insert(InstallTokenTable)
|
||||
.values({
|
||||
id: Identifier.ascending('installToken'),
|
||||
teamId: input.teamId ?? null,
|
||||
organisationId: input.organisationId ?? null,
|
||||
createdByUserId: input.userId,
|
||||
tokenHash,
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
|
||||
})
|
||||
.returning({ expiresAt: InstallTokenTable.expiresAt })
|
||||
.then((rows) => rows[0]!.expiresAt)
|
||||
);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Spend a token and register the machine it was issued for.
|
||||
@@ -81,6 +96,7 @@ export namespace InstallToken {
|
||||
.returning({
|
||||
id: InstallTokenTable.id,
|
||||
teamId: InstallTokenTable.teamId,
|
||||
organisationId: InstallTokenTable.organisationId,
|
||||
userId: InstallTokenTable.createdByUserId
|
||||
})
|
||||
.then((rows) => rows.at(0) ?? null)
|
||||
@@ -91,6 +107,7 @@ export namespace InstallToken {
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: spent.userId,
|
||||
teamId: spent.teamId,
|
||||
organisationId: spent.organisationId,
|
||||
label: input.label
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user