Tenant naming logic adjustments

This commit is contained in:
2026-04-26 18:47:50 +02:00
parent 0bf4c6cf4c
commit 2fdca8b0d3
3 changed files with 238 additions and 7 deletions

View File

@@ -0,0 +1,97 @@
// Standalone JS port of deriveTenantName for offline verification.
// Mirror lib/tenant-naming.ts byte-for-byte logic.
const MAX_NAMESPACE_LEN = 63;
const NAMESPACE_PREFIX = "tenant-";
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length;
const SUFFIX_HEX_LEN = 8;
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1;
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN;
function slugify(input) {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function requestIdSuffix(requestId) {
const hex = requestId.replace(/-/g, "").toLowerCase();
if (!/^[0-9a-f]{8}/.test(hex)) {
throw new Error(`Invalid request id: ${requestId}`);
}
return hex.slice(0, SUFFIX_HEX_LEN);
}
function deriveTenantName(kind, companyName, requestId) {
const suffix = requestIdSuffix(requestId);
if (kind === "personal") return `p-${suffix}`;
const rawSlug = slugify(companyName);
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
if (!slug) return `t-${suffix}`;
return `${slug}-${suffix}`;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
const cases = [
// [kind, companyName, requestId, expected, note]
["company", "Acme GmbH", "abc12345-1234-1234-1234-123456789abc", "acme-gmbh-abc12345", "basic company"],
["company", "Müller AG", "abc12345-aaaa", "m-ller-ag-abc12345", "umlaut → '-'"],
["company", "!!!", "abc12345-aaaa", "t-abc12345", "no alnum → 't-' fallback"],
["personal", "irrelevant", "abc12345-aaaa", "p-abc12345", "personal ignores companyName"],
["personal", "", "abc12345-aaaa", "p-abc12345", "personal with empty companyName"],
["company", " Trim Me ", "abc12345-aaaa", "trim-me-abc12345", "leading/trailing whitespace"],
["company", "Foo---Bar", "abc12345-aaaa", "foo-bar-abc12345", "consecutive hyphens collapse"],
["company", "A very long company name that absolutely will exceed the slug limit easily", "abc12345-aaaa", null, "must be <= 56 chars"],
["company", "----", "abc12345-aaaa", "t-abc12345", "all-hyphen → fallback"],
["company", "ACME", "ABCDEF12-...", "acme-abcdef12", "uppercase UUID is lowercased"],
];
let pass = 0, fail = 0;
for (const [kind, name, id, expected, note] of cases) {
let got;
let err = null;
try {
got = deriveTenantName(kind, name, id);
} catch (e) {
err = e.message;
}
// Special length-only cases
if (expected === null) {
const ok = got && got.length <= 56;
console.log(`${ok ? "PASS" : "FAIL"} len(${got}) = ${got?.length} [${note}]`);
if (ok) pass++; else fail++;
continue;
}
if (err) {
console.log(`THROW ${err} [${note}]`);
if (expected === "throw") pass++; else fail++;
continue;
}
const ok = got === expected;
console.log(`${ok ? "PASS" : "FAIL"} got=${got} want=${expected} [${note}]`);
if (ok) pass++; else fail++;
}
// Should-throw cases
console.log("\nThrow cases:");
const throwCases = [
["company", "Acme", "", "empty requestId"],
["company", "Acme", "xyz", "non-hex requestId"],
["company", "Acme", "1234567", "too short (7 chars)"],
];
for (const [kind, name, id, note] of throwCases) {
let threw = false;
try { deriveTenantName(kind, name, id); } catch { threw = true; }
console.log(`${threw ? "PASS" : "FAIL"} threw=${threw} [${note}]`);
if (threw) pass++; else fail++;
}
console.log(`\n${pass} pass, ${fail} fail`);
process.exit(fail === 0 ? 0 : 1);

View File

@@ -14,6 +14,7 @@ import {
getDefaultAgentsMd,
generateToolsMd,
} from "@/lib/workspace-defaults";
import { deriveTenantName } from "@/lib/tenant-naming";
import { safeError } from "@/lib/errors";
/**
@@ -61,13 +62,14 @@ export async function POST(
const isReApproval = tenantRequest.status === "rejected";
// Derive tenant name from company name: lowercase, alphanumeric + hyphens
const tenantName =
tenantRequest.companyName
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 63) || `tenant-${tenantRequest.id.slice(0, 8)}`;
// Build the CR name: see `lib/tenant-naming.ts` for the format spec.
// For now all approvals are kind="company" — the personal branch is
// wired but unused until Slice 4 introduces the `is_personal` column.
const tenantName = deriveTenantName(
"company",
tenantRequest.companyName,
tenantRequest.id
);
try {
// Step 1: Decrypt and write package secrets to OpenBao (if collected during wizard)

132
src/lib/tenant-naming.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* Deterministic tenant-name derivation for PiecedTenant CRs.
*
* Background
* ----------
* Every PiecedTenant CR's `metadata.name` becomes part of the tenant
* namespace, which the operator builds as `tenant-{name}` (see
* `pieced-operator/api/v1alpha1/piecedtenant_types.go::NamespaceName`).
* Kubernetes namespace names follow the RFC 1123 DNS *label* spec:
* lowercased alphanumeric + hyphens, must start and end with alnum,
* and **max 63 characters**.
*
* That gives us 63 - len("tenant-") = 56 chars to play with for the CR
* name itself. Anything longer is rejected at apply time, so we cap
* here.
*
* Format
* ------
* kind=company → {slug}-{requestIdHex8} e.g. "acme-gmbh-abc12345"
* kind=personal → p-{requestIdHex8} e.g. "p-abc12345"
*
* The 8-hex-char suffix is taken from `tenant_requests.id` (a Postgres
* `gen_random_uuid()` value, set at row insert). Two motivations:
*
* 1. Uniqueness — multiple requests for the same company name no longer
* collide (this is what unblocks Slice 3, multi-tenant per org).
* 2. Stability — the suffix is known at approval time and never changes,
* so the operator and portal agree without coordination. We use the
* request UUID rather than the eventual LiteLLM virtual-key UUID
* because the latter doesn't exist until the operator runs.
*
* 8 hex chars = 32 bits of entropy. Collision probability with 100 active
* tenants per company prefix is ~1e-6; for our pilot scale that's fine.
*
* Limits
* ------
* Suffix is always 8 + 1 (hyphen) = 9 chars. Slug therefore caps at
* 56 - 9 = 47 chars, then we strip any trailing hyphens left by the cut.
*
* Examples
* --------
* deriveTenantName("company", "Acme GmbH", "abc12345-...") = "acme-gmbh-abc12345"
* deriveTenantName("company", "Müller AG", "abc12345-...") = "m-ller-ag-abc12345" (umlaut → "-")
* deriveTenantName("company", "!!!", "abc12345-...") = "t-abc12345" (slug empty → "t-")
* deriveTenantName("personal", "", "abc12345-...") = "p-abc12345"
*/
export type TenantKind = "company" | "personal";
const MAX_NAMESPACE_LEN = 63;
const NAMESPACE_PREFIX = "tenant-";
const MAX_TENANT_NAME_LEN = MAX_NAMESPACE_LEN - NAMESPACE_PREFIX.length; // 56
const SUFFIX_HEX_LEN = 8;
const SUFFIX_TOTAL_LEN = SUFFIX_HEX_LEN + 1; // including the joining "-"
const MAX_SLUG_LEN = MAX_TENANT_NAME_LEN - SUFFIX_TOTAL_LEN; // 47
export class InvalidRequestIdError extends Error {
constructor(requestId: string) {
super(
`Cannot derive tenant name: requestId "${requestId}" does not contain ${SUFFIX_HEX_LEN} hex characters`
);
this.name = "InvalidRequestIdError";
}
}
/**
* Reduce an arbitrary string to a DNS-label-safe slug. Non-alnum runs
* collapse to a single "-"; leading/trailing hyphens are stripped.
*
* Note this does not transliterate Unicode — "Müller" becomes "m-ller",
* not "mueller". That's deliberate: transliteration introduces locale
* dependencies (de-DE vs de-CH vs sv-SE all disagree on ä→a/ä→ae) and
* we'd rather have a stable, ugly slug than a pretty one that changes
* if we touch the locale config later. Customers see the human-readable
* `displayName`, not the slug.
*/
function slugify(input: string): string {
return input
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
/**
* Extract the first 8 hex chars of a UUID string. Strips hyphens and
* lowercases first so callers can pass either "abc12345-..." or
* "ABC12345..." form. Postgres `gen_random_uuid()` already emits the
* canonical lowercase-hyphenated form, so this is just defense in depth
* against any hand-inserted IDs.
*/
function requestIdSuffix(requestId: string): string {
const hex = requestId.replace(/-/g, "").toLowerCase();
if (!/^[0-9a-f]{8}/.test(hex)) {
throw new InvalidRequestIdError(requestId);
}
return hex.slice(0, SUFFIX_HEX_LEN);
}
/**
* Build the PiecedTenant CR `metadata.name` for an approved tenant request.
*
* @param kind "company" for normal customer accounts; "personal"
* for individual accounts (Slice 4 — `is_personal=true`).
* @param companyName Raw display name from the registration. Ignored when
* kind="personal".
* @param requestId `tenant_requests.id` (Postgres UUID).
* @returns A K8s-safe CR name, ≤ 56 chars, with an 8-hex suffix.
*/
export function deriveTenantName(
kind: TenantKind,
companyName: string,
requestId: string
): string {
const suffix = requestIdSuffix(requestId);
if (kind === "personal") {
return `p-${suffix}`;
}
// Company branch: slug-{suffix}, with empty-slug fallback.
const rawSlug = slugify(companyName);
// Cap then re-trim — slicing might leave a dangling hyphen if a non-alnum
// run sat right at the boundary (e.g. "acme-foo-bar-..." cut to "acme-foo-").
const slug = rawSlug.slice(0, MAX_SLUG_LEN).replace(/-+$/, "");
if (!slug) {
return `t-${suffix}`;
}
return `${slug}-${suffix}`;
}