fix: use standard TypeORM repos and header-based tenant extraction

- Replace TenantAwareRepository with standard @InjectRepository
  (TenantAwareRepository requires AsyncLocalStorage tenant context
  middleware which agent-service does not have)
- Replace @TenantId() decorator with @Headers('x-tenant-id')
  for direct HTTP header extraction
- Return defaults gracefully when no tenant is selected

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-21 22:41:30 -08:00
parent f897cfe240
commit d8cb2a9c6f
8 changed files with 110 additions and 90 deletions

View File

@ -1,20 +1,24 @@
/** /**
* Repository for AgentConfig. * Repository for AgentConfig.
* Extends TenantAwareRepository for schema-per-tenant isolation. * Uses standard TypeORM repository (no schema-per-tenant uses tenantId column filter).
*/ */
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { TenantAwareRepository } from '@it0/database'; import { Repository } from 'typeorm';
import { AgentConfig } from '../../domain/entities/agent-config.entity'; import { AgentConfig } from '../../domain/entities/agent-config.entity';
@Injectable() @Injectable()
export class AgentConfigRepository extends TenantAwareRepository<AgentConfig> { export class AgentConfigRepository {
constructor(dataSource: DataSource) { constructor(
super(dataSource, AgentConfig); @InjectRepository(AgentConfig)
} private readonly repo: Repository<AgentConfig>,
) {}
async findByTenantId(tenantId: string): Promise<AgentConfig | null> { async findByTenantId(tenantId: string): Promise<AgentConfig | null> {
const repo = await this.getRepository(); return this.repo.findOneBy({ tenantId });
return repo.findOneBy({ tenantId } as any); }
async save(entity: AgentConfig): Promise<AgentConfig> {
return this.repo.save(entity);
} }
} }

View File

@ -1,30 +1,32 @@
/** /**
* Repository for AgentSkill. * Repository for AgentSkill.
* Extends TenantAwareRepository for schema-per-tenant isolation. * Uses standard TypeORM repository (no schema-per-tenant uses tenantId column filter).
*/ */
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { TenantAwareRepository } from '@it0/database'; import { Repository } from 'typeorm';
import { AgentSkill } from '../../domain/entities/agent-skill.entity'; import { AgentSkill } from '../../domain/entities/agent-skill.entity';
@Injectable() @Injectable()
export class AgentSkillRepository extends TenantAwareRepository<AgentSkill> { export class AgentSkillRepository {
constructor(dataSource: DataSource) { constructor(
super(dataSource, AgentSkill); @InjectRepository(AgentSkill)
} private readonly repo: Repository<AgentSkill>,
) {}
async findByTenantId(tenantId: string): Promise<AgentSkill[]> { async findByTenantId(tenantId: string): Promise<AgentSkill[]> {
const repo = await this.getRepository(); return this.repo.find({ where: { tenantId }, order: { createdAt: 'DESC' } });
return repo.find({ where: { tenantId } as any, order: { createdAt: 'DESC' } });
} }
async findOneByIdAndTenant(id: string, tenantId: string): Promise<AgentSkill | null> { async findOneByIdAndTenant(id: string, tenantId: string): Promise<AgentSkill | null> {
const repo = await this.getRepository(); return this.repo.findOneBy({ id, tenantId });
return repo.findOneBy({ id, tenantId } as any); }
async save(entity: AgentSkill): Promise<AgentSkill> {
return this.repo.save(entity);
} }
async deleteById(id: string): Promise<void> { async deleteById(id: string): Promise<void> {
const repo = await this.getRepository(); await this.repo.delete(id);
await repo.delete(id);
} }
} }

View File

@ -1,30 +1,32 @@
/** /**
* Repository for HookScript. * Repository for HookScript.
* Extends TenantAwareRepository for schema-per-tenant isolation. * Uses standard TypeORM repository (no schema-per-tenant uses tenantId column filter).
*/ */
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { TenantAwareRepository } from '@it0/database'; import { Repository } from 'typeorm';
import { HookScript } from '../../domain/entities/hook-script.entity'; import { HookScript } from '../../domain/entities/hook-script.entity';
@Injectable() @Injectable()
export class HookScriptRepository extends TenantAwareRepository<HookScript> { export class HookScriptRepository {
constructor(dataSource: DataSource) { constructor(
super(dataSource, HookScript); @InjectRepository(HookScript)
} private readonly repo: Repository<HookScript>,
) {}
async findByTenantId(tenantId: string): Promise<HookScript[]> { async findByTenantId(tenantId: string): Promise<HookScript[]> {
const repo = await this.getRepository(); return this.repo.find({ where: { tenantId }, order: { createdAt: 'DESC' } });
return repo.find({ where: { tenantId } as any, order: { createdAt: 'DESC' } });
} }
async findOneByIdAndTenant(id: string, tenantId: string): Promise<HookScript | null> { async findOneByIdAndTenant(id: string, tenantId: string): Promise<HookScript | null> {
const repo = await this.getRepository(); return this.repo.findOneBy({ id, tenantId });
return repo.findOneBy({ id, tenantId } as any); }
async save(entity: HookScript): Promise<HookScript> {
return this.repo.save(entity);
} }
async deleteById(id: string): Promise<void> { async deleteById(id: string): Promise<void> {
const repo = await this.getRepository(); await this.repo.delete(id);
await repo.delete(id);
} }
} }

View File

@ -1,20 +1,24 @@
/** /**
* Repository for TenantAgentConfig. * Repository for TenantAgentConfig.
* Extends TenantAwareRepository for schema-per-tenant isolation (SET search_path). * Uses standard TypeORM repository (no schema-per-tenant uses tenantId column filter).
*/ */
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { TenantAwareRepository } from '@it0/database'; import { Repository } from 'typeorm';
import { TenantAgentConfig } from '../../domain/entities/tenant-agent-config.entity'; import { TenantAgentConfig } from '../../domain/entities/tenant-agent-config.entity';
@Injectable() @Injectable()
export class TenantAgentConfigRepository extends TenantAwareRepository<TenantAgentConfig> { export class TenantAgentConfigRepository {
constructor(dataSource: DataSource) { constructor(
super(dataSource, TenantAgentConfig); @InjectRepository(TenantAgentConfig)
} private readonly repo: Repository<TenantAgentConfig>,
) {}
async findByTenantId(tenantId: string): Promise<TenantAgentConfig | null> { async findByTenantId(tenantId: string): Promise<TenantAgentConfig | null> {
const repo = await this.getRepository(); return this.repo.findOneBy({ tenantId });
return repo.findOneBy({ tenantId } as any); }
async save(entity: TenantAgentConfig): Promise<TenantAgentConfig> {
return this.repo.save(entity);
} }
} }

View File

@ -6,26 +6,26 @@
* POST /api/v1/agent-config Create new config * POST /api/v1/agent-config Create new config
* PUT /api/v1/agent-config/:id Update existing config * PUT /api/v1/agent-config/:id Update existing config
*/ */
import { Controller, Get, Post, Put, Body, Param } from '@nestjs/common'; import { Controller, Get, Post, Put, Body, Param, Headers } from '@nestjs/common';
import { TenantId } from '@it0/common';
import { AgentConfigService, UpdateAgentConfigDto } from '../../../infrastructure/services/agent-config.service'; import { AgentConfigService, UpdateAgentConfigDto } from '../../../infrastructure/services/agent-config.service';
const DEFAULT_CONFIG = {
engine: 'claude-cli',
system_prompt: '',
max_turns: 10,
max_budget: 5.0,
allowed_tools: ['Bash', 'Read', 'Write', 'Glob', 'Grep'],
};
@Controller('api/v1/agent-config') @Controller('api/v1/agent-config')
export class AgentConfigController { export class AgentConfigController {
constructor(private readonly configService: AgentConfigService) {} constructor(private readonly configService: AgentConfigService) {}
@Get() @Get()
async getConfig(@TenantId() tenantId: string) { async getConfig(@Headers('x-tenant-id') tenantId: string) {
if (!tenantId) return DEFAULT_CONFIG;
const config = await this.configService.findByTenantId(tenantId); const config = await this.configService.findByTenantId(tenantId);
if (!config) { if (!config) return DEFAULT_CONFIG;
return {
engine: 'claude-cli',
system_prompt: '',
max_turns: 10,
max_budget: 5.0,
allowed_tools: ['Bash', 'Read', 'Write', 'Glob', 'Grep'],
};
}
return { return {
id: config.id, id: config.id,
@ -39,10 +39,10 @@ export class AgentConfigController {
@Post() @Post()
async createConfig( async createConfig(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Body() dto: UpdateAgentConfigDto, @Body() dto: UpdateAgentConfigDto,
) { ) {
const config = await this.configService.create(tenantId, dto); const config = await this.configService.create(tenantId || 'default', dto);
return { return {
id: config.id, id: config.id,
engine: config.engine, engine: config.engine,
@ -55,11 +55,11 @@ export class AgentConfigController {
@Put(':id') @Put(':id')
async updateConfig( async updateConfig(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateAgentConfigDto, @Body() dto: UpdateAgentConfigDto,
) { ) {
const config = await this.configService.update(id, tenantId, dto); const config = await this.configService.update(id, tenantId || 'default', dto);
if (!config) { if (!config) {
return { error: 'Config not found' }; return { error: 'Config not found' };
} }

View File

@ -1,14 +1,13 @@
/** /**
* REST controller for per-tenant hook scripts (CRUD). * REST controller for per-tenant hook scripts (CRUD).
* *
* Endpoints (all JWT-protected): * Endpoints (JWT validated by Kong gateway):
* GET /api/v1/agent/hooks List all hooks for current tenant * GET /api/v1/agent/hooks List all hooks for current tenant
* POST /api/v1/agent/hooks Create a new hook * POST /api/v1/agent/hooks Create a new hook
* PUT /api/v1/agent/hooks/:id Update an existing hook * PUT /api/v1/agent/hooks/:id Update an existing hook
* DELETE /api/v1/agent/hooks/:id Delete a hook * DELETE /api/v1/agent/hooks/:id Delete a hook
*/ */
import { Controller, Get, Post, Put, Delete, Body, Param, NotFoundException } from '@nestjs/common'; import { Controller, Get, Post, Put, Delete, Body, Param, Headers, NotFoundException } from '@nestjs/common';
import { TenantId } from '@it0/common';
import { HookScriptService, CreateHookDto, UpdateHookDto } from '../../../infrastructure/services/hook-script.service'; import { HookScriptService, CreateHookDto, UpdateHookDto } from '../../../infrastructure/services/hook-script.service';
@Controller('api/v1/agent/hooks') @Controller('api/v1/agent/hooks')
@ -16,25 +15,25 @@ export class HooksController {
constructor(private readonly hookService: HookScriptService) {} constructor(private readonly hookService: HookScriptService) {}
@Get() @Get()
async list(@TenantId() tenantId: string) { async list(@Headers('x-tenant-id') tenantId: string) {
return this.hookService.findAllByTenant(tenantId); return this.hookService.findAllByTenant(tenantId || 'default');
} }
@Post() @Post()
async create( async create(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Body() dto: CreateHookDto, @Body() dto: CreateHookDto,
) { ) {
return this.hookService.create(tenantId, dto); return this.hookService.create(tenantId || 'default', dto);
} }
@Put(':id') @Put(':id')
async update( async update(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateHookDto, @Body() dto: UpdateHookDto,
) { ) {
const hook = await this.hookService.update(id, tenantId, dto); const hook = await this.hookService.update(id, tenantId || 'default', dto);
if (!hook) { if (!hook) {
throw new NotFoundException('Hook not found'); throw new NotFoundException('Hook not found');
} }
@ -43,10 +42,10 @@ export class HooksController {
@Delete(':id') @Delete(':id')
async remove( async remove(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Param('id') id: string, @Param('id') id: string,
) { ) {
const deleted = await this.hookService.delete(id, tenantId); const deleted = await this.hookService.delete(id, tenantId || 'default');
if (!deleted) { if (!deleted) {
throw new NotFoundException('Hook not found'); throw new NotFoundException('Hook not found');
} }

View File

@ -1,14 +1,13 @@
/** /**
* REST controller for per-tenant agent skills (CRUD). * REST controller for per-tenant agent skills (CRUD).
* *
* Endpoints (all JWT-protected): * Endpoints (JWT validated by Kong gateway):
* GET /api/v1/agent/skills List all skills for current tenant * GET /api/v1/agent/skills List all skills for current tenant
* POST /api/v1/agent/skills Create a new skill * POST /api/v1/agent/skills Create a new skill
* PUT /api/v1/agent/skills/:id Update an existing skill * PUT /api/v1/agent/skills/:id Update an existing skill
* DELETE /api/v1/agent/skills/:id Delete a skill * DELETE /api/v1/agent/skills/:id Delete a skill
*/ */
import { Controller, Get, Post, Put, Delete, Body, Param, NotFoundException } from '@nestjs/common'; import { Controller, Get, Post, Put, Delete, Body, Param, Headers, NotFoundException } from '@nestjs/common';
import { TenantId } from '@it0/common';
import { AgentSkillService, CreateSkillDto, UpdateSkillDto } from '../../../infrastructure/services/agent-skill.service'; import { AgentSkillService, CreateSkillDto, UpdateSkillDto } from '../../../infrastructure/services/agent-skill.service';
@Controller('api/v1/agent/skills') @Controller('api/v1/agent/skills')
@ -16,25 +15,25 @@ export class SkillsController {
constructor(private readonly skillService: AgentSkillService) {} constructor(private readonly skillService: AgentSkillService) {}
@Get() @Get()
async list(@TenantId() tenantId: string) { async list(@Headers('x-tenant-id') tenantId: string) {
return this.skillService.findAllByTenant(tenantId); return this.skillService.findAllByTenant(tenantId || 'default');
} }
@Post() @Post()
async create( async create(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Body() dto: CreateSkillDto, @Body() dto: CreateSkillDto,
) { ) {
return this.skillService.create(tenantId, dto); return this.skillService.create(tenantId || 'default', dto);
} }
@Put(':id') @Put(':id')
async update( async update(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Param('id') id: string, @Param('id') id: string,
@Body() dto: UpdateSkillDto, @Body() dto: UpdateSkillDto,
) { ) {
const skill = await this.skillService.update(id, tenantId, dto); const skill = await this.skillService.update(id, tenantId || 'default', dto);
if (!skill) { if (!skill) {
throw new NotFoundException('Skill not found'); throw new NotFoundException('Skill not found');
} }
@ -43,10 +42,10 @@ export class SkillsController {
@Delete(':id') @Delete(':id')
async remove( async remove(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Param('id') id: string, @Param('id') id: string,
) { ) {
const deleted = await this.skillService.delete(id, tenantId); const deleted = await this.skillService.delete(id, tenantId || 'default');
if (!deleted) { if (!deleted) {
throw new NotFoundException('Skill not found'); throw new NotFoundException('Skill not found');
} }

View File

@ -1,15 +1,14 @@
/** /**
* Admin REST controller for per-tenant Agent SDK configuration. * Admin REST controller for per-tenant Agent SDK configuration.
* *
* Endpoints (all JWT-protected): * Endpoints (JWT validated by Kong gateway):
* GET /api/v1/agent/tenant-config Get current tenant's SDK config (returns defaults if none set) * GET /api/v1/agent/tenant-config Get current tenant's SDK config (returns defaults if none set)
* PUT /api/v1/agent/tenant-config Create/update config (billingMode, apiKey, timeout, tools) * PUT /api/v1/agent/tenant-config Create/update config (billingMode, apiKey, timeout, tools)
* DELETE /api/v1/agent/tenant-config/api-key Remove API key, revert to subscription billing * DELETE /api/v1/agent/tenant-config/api-key Remove API key, revert to subscription billing
* *
* Note: API key is never returned in responses only `hasApiKey: boolean` is exposed. * Note: API key is never returned in responses only `hasApiKey: boolean` is exposed.
*/ */
import { Controller, Get, Put, Delete, Body, NotFoundException } from '@nestjs/common'; import { Controller, Get, Put, Delete, Body, Headers, NotFoundException } from '@nestjs/common';
import { TenantId } from '@it0/common';
import { TenantAgentConfigService, UpdateTenantAgentConfigDto } from '../../../infrastructure/services/tenant-agent-config.service'; import { TenantAgentConfigService, UpdateTenantAgentConfigDto } from '../../../infrastructure/services/tenant-agent-config.service';
@Controller('api/v1/agent/tenant-config') @Controller('api/v1/agent/tenant-config')
@ -19,7 +18,18 @@ export class TenantAgentConfigController {
) {} ) {}
@Get() @Get()
async getConfig(@TenantId() tenantId: string) { async getConfig(@Headers('x-tenant-id') tenantId: string) {
if (!tenantId) {
return {
tenantId: '',
billingMode: 'subscription',
approvalTimeoutSeconds: 120,
toolWhitelist: [],
toolBlacklist: [],
hasApiKey: false,
};
}
const config = await this.tenantConfigService.findByTenantId(tenantId); const config = await this.tenantConfigService.findByTenantId(tenantId);
if (!config) { if (!config) {
return { return {
@ -46,10 +56,10 @@ export class TenantAgentConfigController {
@Put() @Put()
async upsertConfig( async upsertConfig(
@TenantId() tenantId: string, @Headers('x-tenant-id') tenantId: string,
@Body() dto: UpdateTenantAgentConfigDto, @Body() dto: UpdateTenantAgentConfigDto,
) { ) {
const config = await this.tenantConfigService.upsert(tenantId, dto); const config = await this.tenantConfigService.upsert(tenantId || 'default', dto);
return { return {
tenantId: config.tenantId, tenantId: config.tenantId,
billingMode: config.billingMode, billingMode: config.billingMode,
@ -62,8 +72,8 @@ export class TenantAgentConfigController {
} }
@Delete('api-key') @Delete('api-key')
async removeApiKey(@TenantId() tenantId: string) { async removeApiKey(@Headers('x-tenant-id') tenantId: string) {
const config = await this.tenantConfigService.removeApiKey(tenantId); const config = await this.tenantConfigService.removeApiKey(tenantId || 'default');
if (!config) { if (!config) {
throw new NotFoundException('No agent config found for this tenant'); throw new NotFoundException('No agent config found for this tenant');
} }