From bc2303d3b84acd19442498fe5d87fd5e41f63999 Mon Sep 17 00:00:00 2001 From: Matias Fontanini Date: Wed, 25 Feb 2026 15:44:09 -0800 Subject: [PATCH] feat: split api tokens into separate table --- .../1771977600000-ApiTokensTable.ts | 43 +++++++++++++++++++ nilcc-api/src/account/account.controller.ts | 7 ++- nilcc-api/src/account/account.entity.ts | 2 +- nilcc-api/src/account/account.service.ts | 28 +++++++++--- nilcc-api/src/api-token/api-token.entity.ts | 17 ++++++++ nilcc-api/src/data-source.ts | 6 ++- 6 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 nilcc-api/migrations/1771977600000-ApiTokensTable.ts create mode 100644 nilcc-api/src/api-token/api-token.entity.ts diff --git a/nilcc-api/migrations/1771977600000-ApiTokensTable.ts b/nilcc-api/migrations/1771977600000-ApiTokensTable.ts new file mode 100644 index 00000000..1ae8fbd6 --- /dev/null +++ b/nilcc-api/migrations/1771977600000-ApiTokensTable.ts @@ -0,0 +1,43 @@ +import type { MigrationInterface, QueryRunner } from "typeorm"; + +export class ApiTokensTable1771977600000 implements MigrationInterface { + name = "ApiTokensTable1771977600000"; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE api_tokens ( + id character varying NOT NULL, + token character varying NOT NULL, + account_id character varying NOT NULL, + created_at TIMESTAMP NOT NULL, + CONSTRAINT pk_api_tokens PRIMARY KEY (id) + ) + `); + await queryRunner.query( + "CREATE UNIQUE INDEX api_tokens_token_idx ON api_tokens (token)", + ); + await queryRunner.query(` + ALTER TABLE api_tokens + ADD CONSTRAINT fk_api_tokens_account_id + FOREIGN KEY (account_id) REFERENCES accounts(id) + `); + await queryRunner.query(` + INSERT INTO api_tokens (id, token, created_at) + SELECT gen_random_uuid()::text, api_token, NOW() + FROM accounts + `); + await queryRunner.query("DROP INDEX account_entity_api_token_idx"); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + "ALTER TABLE accounts ALTER COLUMN api_token SET NOT NULL", + ); + await queryRunner.query( + "CREATE UNIQUE INDEX account_entity_api_token_idx ON accounts (api_token)", + ); + await queryRunner.query( + "ALTER TABLE api_tokens DROP CONSTRAINT fk_api_tokens_account_id", + ); + } +} diff --git a/nilcc-api/src/account/account.controller.ts b/nilcc-api/src/account/account.controller.ts index 3a21e223..c0b23b2a 100644 --- a/nilcc-api/src/account/account.controller.ts +++ b/nilcc-api/src/account/account.controller.ts @@ -42,9 +42,14 @@ export function create(options: ControllerOptions) { }), adminAuthentication(bindings), payloadValidator(CreateAccountRequest), + transactionMiddleware(bindings.dataSource), async (c) => { const payload = c.req.valid("json"); - const account = await bindings.services.account.create(bindings, payload); + const account = await bindings.services.account.create( + bindings, + payload, + c.get("txQueryRunner"), + ); return c.json(accountMapper.entityToResponse(account)); }, ); diff --git a/nilcc-api/src/account/account.entity.ts b/nilcc-api/src/account/account.entity.ts index ba25e523..db3028db 100644 --- a/nilcc-api/src/account/account.entity.ts +++ b/nilcc-api/src/account/account.entity.ts @@ -9,7 +9,7 @@ export class AccountEntity { @Column({ type: "varchar", unique: true }) name: string; - @Column({ type: "varchar", unique: true }) + @Column({ type: "varchar" }) apiToken: string; @Column({ type: "int" }) diff --git a/nilcc-api/src/account/account.service.ts b/nilcc-api/src/account/account.service.ts index 7a7323fb..ffa466c0 100644 --- a/nilcc-api/src/account/account.service.ts +++ b/nilcc-api/src/account/account.service.ts @@ -1,6 +1,7 @@ import * as crypto from "node:crypto"; import { In, type QueryRunner, type Repository } from "typeorm"; import { v4 as uuidv4 } from "uuid"; +import { ApiTokenEntity } from "#/api-token/api-token.entity"; import { EntityAlreadyExists, EntityNotFound, @@ -31,16 +32,26 @@ export class AccountService { async create( bindings: AppBindings, request: CreateAccountRequest, + tx: QueryRunner, ): Promise { - const repository = this.getRepository(bindings); + const accountRepository = this.getRepository(bindings, tx); + const apiTokensRepository = tx.manager.getRepository(ApiTokenEntity); try { - return await repository.save({ + const token = crypto.randomBytes(API_TOKEN_BYTE_LENGTH).toString("hex"); + const account = await accountRepository.save({ id: uuidv4(), name: request.name, - apiToken: crypto.randomBytes(API_TOKEN_BYTE_LENGTH).toString("hex"), + apiToken: token, createdAt: new Date(), credits: request.credits, }); + await apiTokensRepository.save({ + id: uuidv4(), + token, + account, + createdAt: bindings.services.time.getTime(), + }); + return account; } catch (e: unknown) { if (isUniqueConstraint(e)) { throw new EntityAlreadyExists("account"); @@ -72,8 +83,15 @@ export class AccountService { bindings: AppBindings, apiToken: string, ): Promise { - const repository = this.getRepository(bindings); - return await repository.findOneBy({ apiToken }); + const tokenRepository = bindings.dataSource.getRepository(ApiTokenEntity); + const tokens = await tokenRepository.find({ + where: { token: apiToken }, + relations: ["account"], + }); + if (tokens.length === 0) { + return null; + } + return tokens[0].account; } async list(bindings: AppBindings): Promise { diff --git a/nilcc-api/src/api-token/api-token.entity.ts b/nilcc-api/src/api-token/api-token.entity.ts new file mode 100644 index 00000000..cd20f8e9 --- /dev/null +++ b/nilcc-api/src/api-token/api-token.entity.ts @@ -0,0 +1,17 @@ +import { Column, Entity, ManyToOne, PrimaryColumn } from "typeorm"; +import { AccountEntity } from "#/account/account.entity"; + +@Entity({ name: "api_tokens" }) +export class ApiTokenEntity { + @PrimaryColumn({ type: "varchar" }) + id: string; + + @Column({ type: "varchar", unique: true }) + token: string; + + @ManyToOne(() => AccountEntity, { onDelete: "CASCADE" }) + account: AccountEntity; + + @Column({ type: "timestamp" }) + createdAt: Date; +} diff --git a/nilcc-api/src/data-source.ts b/nilcc-api/src/data-source.ts index 33d333f3..e495f308 100644 --- a/nilcc-api/src/data-source.ts +++ b/nilcc-api/src/data-source.ts @@ -24,6 +24,7 @@ import { CreateArtifactsTable1758563696154 } from "migrations/1758563696154-Crea import { MetalInstanceArtifactVersions1758645450546 } from "migrations/1758645450546-MetalInstanceArtifactVersions"; import { ArtifactVersionMandatory1758656531192 } from "migrations/1758656531192-ArtifactVersionMandatory"; import { WorkloadHeartbeats1765485856928 } from "migrations/1765485856928-WorkloadHeartbeats"; +import { ApiTokensTable1771977600000 } from "migrations/1771977600000-ApiTokensTable"; import { DataSource } from "typeorm"; import type { EnvVars } from "#/env"; import { MetalInstanceEntity } from "#/metal-instance/metal-instance.entity"; @@ -32,6 +33,7 @@ import { WorkloadEventEntity, } from "#/workload/workload.entity"; import { AccountEntity } from "./account/account.entity"; +import { ApiTokenEntity } from "./api-token/api-token.entity"; import { ArtifactEntity } from "./artifact/artifact.entity"; import { WorkloadTierEntity } from "./workload-tier/workload-tier.entity"; @@ -41,9 +43,10 @@ export async function buildDataSource(config: EnvVars): Promise { url: config.dbUri, entities: [ AccountEntity, + ApiTokenEntity, ArtifactEntity, - WorkloadEntity, MetalInstanceEntity, + WorkloadEntity, WorkloadEventEntity, WorkloadTierEntity, ], @@ -64,6 +67,7 @@ export async function buildDataSource(config: EnvVars): Promise { MetalInstanceArtifactVersions1758645450546, ArtifactVersionMandatory1758656531192, WorkloadHeartbeats1765485856928, + ApiTokensTable1771977600000, ], synchronize: false, logging: false,