mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
feat(machine): bound how many install tokens one person can mint
An install token is minted by a signed-in call a script can repeat, and each one is a host waiting to be added. Nothing limited how many could be made or left lying unspent. Minting now refuses, per person, past 5 tokens in a rolling hour or 3 that are unspent and unexpired, with 429 `rate_limit` / `too_many_requests` and a message a terminal can print as it is. The counts are taken while holding the person's own row, so two requests at the same instant cannot both pass.
This commit is contained in:
@@ -176,7 +176,7 @@ export namespace MachineApi {
|
||||
tags: ['Machine'],
|
||||
summary: 'Issue an install token',
|
||||
description:
|
||||
'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.',
|
||||
'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. One person may make five an hour and hold three unspent at once.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -194,7 +194,8 @@ export namespace MachineApi {
|
||||
description: 'A token for one host'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
403: ErrorResponses[403],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
|
||||
@@ -118,3 +118,44 @@ describe('a sign-in made by the installer', () => {
|
||||
expect((await call('POST', '/machine/install-token', token, {})).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('how many install tokens one person may have', () => {
|
||||
test('three unspent at once, then a 429 a terminal can print', async () => {
|
||||
const owner = await person('limit-outstanding');
|
||||
const token = await tokenFor(owner.userId, 'installer');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
expect((await call('POST', '/machine/install-token', token, {})).status).toBe(200);
|
||||
}
|
||||
const refused = await call('POST', '/machine/install-token', token, {});
|
||||
expect(refused.status).toBe(429);
|
||||
expect((await refused.json()) as any).toEqual({
|
||||
type: 'rate_limit',
|
||||
code: 'too_many_requests',
|
||||
message:
|
||||
'You have 3 install tokens that have not been used yet. Use one, or wait for them to expire.'
|
||||
});
|
||||
});
|
||||
|
||||
test('five an hour, even when every one was spent', async () => {
|
||||
const owner = await person('limit-hourly');
|
||||
const token = await tokenFor(owner.userId, 'installer');
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const issued = await call('POST', '/machine/install-token', token, {});
|
||||
expect(issued.status).toBe(200);
|
||||
const { data } = (await issued.json()) as any;
|
||||
const installed = await app.request('/machine/install', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token: data.token, label: `host-${i}` })
|
||||
});
|
||||
expect(installed.status).toBe(200);
|
||||
}
|
||||
const refused = await call('POST', '/machine/install-token', token, {});
|
||||
expect(refused.status).toBe(429);
|
||||
expect((await refused.json()) as any).toEqual({
|
||||
type: 'rate_limit',
|
||||
code: 'too_many_requests',
|
||||
message: 'You have made 5 install tokens in the last hour. Try again later.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ Branch: feat/installer-sign-in
|
||||
- 2. per-address limit on POST /device/authorize (engine `deviceVerification.requestLimit`), 429 slow_down + Retry-After
|
||||
- 3. confirm wording per client (engine `deviceConfirm`, apps/auth `deviceConfirm`), preview entry
|
||||
- 4. api middleware: aud `installer` only on POST /machine/install-token, else 403 (test: apps/api/test/install-token.test.ts)
|
||||
- 5. core InstallToken.create: 5/h + 3 outstanding per user (HOURLY_LIMIT, OUTSTANDING_LIMIT), 429 too_many_requests
|
||||
|
||||
## Left
|
||||
1. issuer: `installer` device client, 15 min access, no refresh token
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { Fixtures } from '../db/fixtures.js';
|
||||
import { testDb } from '../db/test.js';
|
||||
import { VisibleError } from '../error.js';
|
||||
import { InstallToken } from './install-token.js';
|
||||
|
||||
const sql = testDb();
|
||||
@@ -67,4 +68,56 @@ describe('Install tokens', () => {
|
||||
expect(await InstallToken.redeem({ token, label: 'late' })).toBeNull();
|
||||
expect(await InstallToken.redeem({ token: 'nit_nosuchtoken', label: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
test('one person may hold three unspent at once', async () => {
|
||||
const owner = await newOwner('nit-outstanding');
|
||||
const mint = () => InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
|
||||
for (let i = 0; i < InstallToken.OUTSTANDING_LIMIT; i++) await mint();
|
||||
|
||||
const refused = await mint().catch((e) => e);
|
||||
expect(refused).toBeInstanceOf(VisibleError);
|
||||
expect(refused.type).toBe('rate_limit');
|
||||
expect(refused.code).toBe('too_many_requests');
|
||||
expect(refused.message).toBe(
|
||||
'You have 3 install tokens that have not been used yet. Use one, or wait for them to expire.'
|
||||
);
|
||||
|
||||
// One expiring frees its place.
|
||||
await sql`update install_token set expires_at = now() - interval '1 minute'
|
||||
where id = (select id from install_token where created_by_user_id = ${owner.userId} limit 1)`;
|
||||
expect((await mint()).token).toStartWith('nit_');
|
||||
});
|
||||
|
||||
test('one person may make five an hour, spent or not', async () => {
|
||||
const owner = await newOwner('nit-hourly');
|
||||
const mint = () => InstallToken.create({ teamId: owner.teamId, userId: owner.userId });
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const { token } = await mint();
|
||||
await InstallToken.redeem({ token, label: `h${i}` });
|
||||
}
|
||||
await mint();
|
||||
await mint();
|
||||
|
||||
const refused = await mint().catch((e) => e);
|
||||
expect(refused).toBeInstanceOf(VisibleError);
|
||||
expect(refused.code).toBe('too_many_requests');
|
||||
expect(refused.message).toBe(
|
||||
'You have made 5 install tokens in the last hour. Try again later.'
|
||||
);
|
||||
|
||||
// The hour is rolling: once the first ones age out, there is room again.
|
||||
await sql`update install_token set time_created = now() - interval '61 minutes'
|
||||
where created_by_user_id = ${owner.userId}`;
|
||||
expect((await mint()).token).toStartWith('nit_');
|
||||
});
|
||||
|
||||
test('the limits are per person', async () => {
|
||||
const busy = await newOwner('nit-busy');
|
||||
const quiet = await newOwner('nit-quiet');
|
||||
for (let i = 0; i < InstallToken.OUTSTANDING_LIMIT; i++) {
|
||||
await InstallToken.create({ teamId: busy.teamId, userId: busy.userId });
|
||||
}
|
||||
const issued = await InstallToken.create({ teamId: quiet.teamId, userId: quiet.userId });
|
||||
expect(issued.token).toStartWith('nit_');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import { and, eq, gt, isNull, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { Machine } from './index.js';
|
||||
import { InstallTokenTable } from './install-token.sql.js';
|
||||
|
||||
@@ -20,6 +22,15 @@ export namespace InstallToken {
|
||||
*/
|
||||
export const TTL_MINUTES = 60;
|
||||
|
||||
/**
|
||||
* How many one person may ask for. A token is minted by a signed-in call
|
||||
* that a script can repeat, and each one is a host waiting to happen, so
|
||||
* minting is bounded twice: by rate, and by how many can be lying unspent
|
||||
* at once. Both are far above what installing by hand ever needs.
|
||||
*/
|
||||
export const HOURLY_LIMIT = 5;
|
||||
export const OUTSTANDING_LIMIT = 3;
|
||||
|
||||
function generate(): string {
|
||||
return `nit_${randomBytes(TOKEN_BYTES).toString('base64url')}`;
|
||||
}
|
||||
@@ -34,6 +45,9 @@ export namespace InstallToken {
|
||||
/**
|
||||
* Issue a token for a team, or for an organisation's fleet. The caller has
|
||||
* already checked membership of whichever it names.
|
||||
*
|
||||
* Refused once the person has hit {@link HOURLY_LIMIT} or
|
||||
* {@link OUTSTANDING_LIMIT}, with a message a terminal can print as it is.
|
||||
*/
|
||||
export const create = fn(
|
||||
z
|
||||
@@ -48,8 +62,38 @@ export namespace InstallToken {
|
||||
async (input) => {
|
||||
const token = generate();
|
||||
const tokenHash = await digest(token);
|
||||
const expiresAt = await Database.use(async (tx) =>
|
||||
tx
|
||||
const expiresAt = await Database.transaction(async (tx) => {
|
||||
// The limits are counts, so they hold only if nothing else can
|
||||
// mint for this person while they are taken. Their own row is
|
||||
// the one thing every such call contends on.
|
||||
await tx
|
||||
.select({ id: UserTable.id })
|
||||
.from(UserTable)
|
||||
.where(eq(UserTable.id, input.userId))
|
||||
.for('update');
|
||||
const [usage] = await tx
|
||||
.select({
|
||||
lastHour: sql<number>`(count(*) filter (where ${InstallTokenTable.timeCreated} > now() - interval '1 hour'))::int`,
|
||||
outstanding: sql<number>`(count(*) filter (where ${InstallTokenTable.redeemedAt} is null and ${InstallTokenTable.timeDeleted} is null and ${InstallTokenTable.expiresAt} > now()))::int`
|
||||
})
|
||||
.from(InstallTokenTable)
|
||||
.where(eq(InstallTokenTable.createdByUserId, input.userId));
|
||||
if ((usage?.lastHour ?? 0) >= HOURLY_LIMIT) {
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.RateLimit.TOO_MANY_REQUESTS,
|
||||
`You have made ${HOURLY_LIMIT} install tokens in the last hour. Try again later.`
|
||||
);
|
||||
}
|
||||
if ((usage?.outstanding ?? 0) >= OUTSTANDING_LIMIT) {
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.RateLimit.TOO_MANY_REQUESTS,
|
||||
`You have ${OUTSTANDING_LIMIT} install tokens that have not been used yet. Use one, or wait for them to expire.`
|
||||
);
|
||||
}
|
||||
|
||||
return tx
|
||||
.insert(InstallTokenTable)
|
||||
.values({
|
||||
id: Identifier.ascending('installToken'),
|
||||
@@ -60,8 +104,8 @@ export namespace InstallToken {
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
|
||||
})
|
||||
.returning({ expiresAt: InstallTokenTable.expiresAt })
|
||||
.then((rows) => rows[0]!.expiresAt)
|
||||
);
|
||||
.then((rows) => rows[0]!.expiresAt);
|
||||
});
|
||||
return { token, expiresAt };
|
||||
}
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user