Harden tenant lifecycle validation

Reject empty tenant input, normalize read-path lookups, and treat shared platform resource aliases as reserved so lifecycle validation catches underscore and hyphen collisions consistently.

---
Build: pass | Tests: pass — 25 passed (2 files)
This commit is contained in:
Mevy Assistant 2026-04-24 08:38:29 +02:00
parent e040f5cfcc
commit 311f663523
6 changed files with 161 additions and 64 deletions

View file

@ -156,6 +156,11 @@ Also updated:
- `just tenant-plan <tenant>`
- `just tenant-add <tenant>`
- `just tenant-remove <tenant>` (dry-run)
- tenant lifecycle validation is now stricter:
- empty tenant names are rejected
- shared/platform resource aliases are reserved, even when underscores and
hyphens differ
- lifecycle lookups normalize operator input on read paths too
## Recommended first code tasks

View file

@ -73,60 +73,66 @@ function printRemovalPlan(
}
function main(argv: string[]): void {
const [command, ...rest] = argv;
if (!command) usage();
try {
const [command, ...rest] = argv;
if (!command) usage();
if (command === 'list') {
for (const tenant of listTenantRecords()) {
console.log(`${tenant.id}\t${tenant.service}`);
if (command === 'list') {
for (const tenant of listTenantRecords()) {
console.log(`${tenant.id}\t${tenant.service}`);
}
return;
}
return;
}
const options = parseOptions(rest);
if (!options.tenantId) usage();
const options = parseOptions(rest);
if (!options.tenantId) usage();
if (command === 'show') {
const tenant = getTenantRecord(options.tenantId);
if (!tenant) {
console.error(`Unknown tenant: ${options.tenantId}`);
process.exit(1);
if (command === 'show') {
const tenant = getTenantRecord(options.tenantId);
if (!tenant) {
console.error(`Unknown tenant: ${options.tenantId}`);
process.exit(1);
}
printTenantSummary(tenant);
return;
}
printTenantSummary(tenant);
return;
}
if (command === 'plan') {
const draft = deriveTenantRecord(options.tenantId, {
displayName: options.displayName,
domain: options.domain,
});
console.log(
tenantExists(draft.id)
? `Tenant ${draft.id} already exists in registry. Derived plan shown for comparison.`
: `Derived plan for new tenant ${draft.id}:`,
);
printTenantSummary(draft);
return;
}
if (command === 'plan') {
const draft = deriveTenantRecord(options.tenantId, {
displayName: options.displayName,
domain: options.domain,
});
console.log(
tenantExists(draft.id)
? `Tenant ${draft.id} already exists in registry. Derived plan shown for comparison.`
: `Derived plan for new tenant ${draft.id}:`,
);
printTenantSummary(draft);
return;
}
if (command === 'add') {
const added = addTenantRecord(options.tenantId, {
displayName: options.displayName,
domain: options.domain,
});
console.log(`Added tenant ${added.id} to infra/tenants.yaml`);
printTenantSummary(added);
return;
}
if (command === 'add') {
const added = addTenantRecord(options.tenantId, {
displayName: options.displayName,
domain: options.domain,
});
console.log(`Added tenant ${added.id} to infra/tenants.yaml`);
printTenantSummary(added);
return;
}
if (command === 'remove') {
const plan = planTenantRemoval(options.tenantId);
printRemovalPlan(plan);
return;
}
if (command === 'remove') {
const plan = planTenantRemoval(options.tenantId);
printRemovalPlan(plan);
return;
}
usage();
usage();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exit(1);
}
}
main(process.argv.slice(2));

View file

@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
deriveTenantId,
defaultTenantDisplayName,
normalizePlatformId,
normalizeTenantId,
@ -28,6 +29,10 @@ describe('platform-layout', () => {
expect(normalizeTenantId('ščćž')).toBe('sccz');
});
it('rejects empty tenant names when deriving tenant ids', () => {
expect(() => deriveTenantId(' ')).toThrow('Tenant name cannot be empty');
});
it('resolves tenant ids and platform service names from explicit and legacy inputs', () => {
expect(resolveTenantId('Mevy Agent')).toBe('mevy-agent');
expect(resolveTenantId(undefined)).toBe('clawdie');

View file

@ -89,6 +89,14 @@ export function normalizeTenantId(value: string): string {
return normalizeResourceId(value);
}
export function deriveTenantId(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
throw new Error('Tenant name cannot be empty');
}
return normalizeTenantId(trimmed);
}
export function resolveTenantId(
tenantId?: string | null,
fallback = 'clawdie',

View file

@ -111,6 +111,20 @@ describe('tenant-registry', () => {
).toThrow('Tenant service conflicts with shared service: clawdie_hostd');
});
it('rejects tenant ids reserved by shared resource aliases', () => {
const registryPath = makeTempRegistry();
expect(() => addTenantRecord('clawdie-hostd', {}, registryPath)).toThrow(
'Tenant id is reserved by platform resources: clawdie-hostd',
);
});
it('rejects empty tenant input before deriving a registry record', () => {
const registryPath = makeTempRegistry();
expect(() => addTenantRecord(' ', {}, registryPath)).toThrow(
'Tenant name cannot be empty',
);
});
it('can write a registry round-trip', () => {
const registryPath = makeTempRegistry();
const registry = loadTenantRegistry(registryPath);
@ -138,4 +152,9 @@ describe('tenant-registry', () => {
'Unknown tenant: unknown',
);
});
it('normalizes tenant lookups on read paths', () => {
expect(getTenantRecord('Mevy Agent')).toBeNull();
expect(getTenantRecord('MEVY')?.id).toBe('mevy');
});
});

View file

@ -5,8 +5,11 @@ import { fileURLToPath } from 'url';
import { parse, stringify } from 'yaml';
import { z } from 'zod';
import { normalizeDbIdentifierBase } from './db-identifiers.js';
import {
deriveTenantId,
defaultTenantDisplayName,
normalizeResourceId,
tenantBrainDbName,
tenantForgejoDbName,
tenantInternalDomain,
@ -14,7 +17,6 @@ import {
tenantServiceName,
tenantSkillsDbName,
tenantWorkerJailName,
normalizeTenantId,
} from './platform-layout.js';
const __filename = fileURLToPath(import.meta.url);
@ -141,11 +143,32 @@ function deriveDisplayName(rawTenantInput: string, normalizedId: string): string
return raw === normalizedId ? defaultTenantDisplayName(normalizedId) : raw;
}
function canonicalResourceAlias(value: string): string {
return normalizeResourceId(value);
}
function canonicalDomain(value: string): string {
return value.trim().toLowerCase().replace(/\.+$/u, '');
}
function canonicalDbAlias(value: string): string {
return normalizeDbIdentifierBase(value);
}
function hasCanonicalOverlap(
left: string[],
right: string[],
canonicalize: (value: string) => string,
): boolean {
const values = new Set(left.map(canonicalize));
return right.some((item) => values.has(canonicalize(item)));
}
export function deriveTenantRecord(
tenantId: string,
options: TenantDraftOptions = {},
): TenantRecord {
const id = normalizeTenantId(tenantId);
const id = deriveTenantId(tenantId);
return {
id,
displayName: options.displayName || deriveDisplayName(tenantId, id),
@ -208,7 +231,7 @@ export function getTenantRecord(
tenantId: string,
registry = loadTenantRegistry(),
): TenantRecord | null {
return registry.tenants[tenantId] || null;
return registry.tenants[deriveTenantId(tenantId)] || null;
}
export function listTenantRecords(
@ -223,22 +246,35 @@ export function tenantExists(
tenantId: string,
registry = loadTenantRegistry(),
): boolean {
return !!getTenantRecord(normalizeTenantId(tenantId), registry);
}
function hasOverlap(left: string[], right: string[]): boolean {
const values = new Set(left);
return right.some((item) => values.has(item));
return !!getTenantRecord(deriveTenantId(tenantId), registry);
}
function validateTenantRecord(
candidate: TenantRecord,
registry: PlatformRegistry,
): void {
if (registry.shared.services.includes(candidate.service)) {
throw new Error(`Tenant service conflicts with shared service: ${candidate.service}`);
const candidateIdAlias = canonicalResourceAlias(candidate.id);
const sharedServiceAliases = new Set(
registry.shared.services.map(canonicalResourceAlias),
);
const sharedJailAliases = new Set(
registry.shared.jails.map(canonicalResourceAlias),
);
const reservedIdAliases = new Set([
canonicalResourceAlias(registry.platform.id),
...sharedServiceAliases,
...sharedJailAliases,
]);
if (reservedIdAliases.has(candidateIdAlias)) {
throw new Error(`Tenant id is reserved by platform resources: ${candidate.id}`);
}
if (registry.shared.jails.includes(candidate.service)) {
if (sharedServiceAliases.has(canonicalResourceAlias(candidate.service))) {
throw new Error(
`Tenant service conflicts with shared service: ${candidate.service}`,
);
}
if (sharedJailAliases.has(canonicalResourceAlias(candidate.service))) {
throw new Error(`Tenant service conflicts with shared jail: ${candidate.service}`);
}
@ -246,23 +282,41 @@ function validateTenantRecord(
if (existing.id === candidate.id) {
throw new Error(`Tenant already exists: ${candidate.id}`);
}
if (existing.service === candidate.service) {
throw new Error(`Tenant service conflicts with existing tenant: ${candidate.service}`);
if (
canonicalResourceAlias(existing.service) ===
canonicalResourceAlias(candidate.service)
) {
throw new Error(
`Tenant service conflicts with existing tenant: ${candidate.service}`,
);
}
if (existing.internalDomain === candidate.internalDomain) {
if (
canonicalDomain(existing.internalDomain) ===
canonicalDomain(candidate.internalDomain)
) {
throw new Error(
`Tenant internal domain conflicts with existing tenant: ${candidate.internalDomain}`,
);
}
if (hasOverlap(existing.workerJails, candidate.workerJails)) {
if (
hasCanonicalOverlap(
existing.workerJails,
candidate.workerJails,
canonicalResourceAlias,
)
) {
throw new Error(`Tenant worker jails conflict with existing tenant: ${candidate.id}`);
}
if (
hasOverlap(Object.values(existing.databases), Object.values(candidate.databases))
hasCanonicalOverlap(
Object.values(existing.databases),
Object.values(candidate.databases),
canonicalDbAlias,
)
) {
throw new Error(`Tenant databases conflict with existing tenant: ${candidate.id}`);
}
if (hasOverlap(existing.datasets, candidate.datasets)) {
if (hasCanonicalOverlap(existing.datasets, candidate.datasets, canonicalDomain)) {
throw new Error(`Tenant datasets conflict with existing tenant: ${candidate.id}`);
}
}
@ -340,7 +394,7 @@ export function planTenantRemoval(
registryPath = REGISTRY_PATH,
): TenantRemovalPlan {
const registry = loadTenantRegistry(registryPath);
const normalizedId = normalizeTenantId(tenantId);
const normalizedId = deriveTenantId(tenantId);
const tenant = getTenantRecord(normalizedId, registry);
if (!tenant) {