This commit is contained in:
@@ -55,6 +55,7 @@ const MIGRATION_SQL = `
|
||||
admin_notes TEXT,
|
||||
tenant_name TEXT,
|
||||
encrypted_secrets BYTEA,
|
||||
is_personal BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -70,6 +71,7 @@ const MIGRATION_SQL = `
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS encrypted_secrets BYTEA;
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS agents_md TEXT;
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS instance_name TEXT;
|
||||
ALTER TABLE tenant_requests ADD COLUMN IF NOT EXISTS is_personal BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Slice 3: drop the legacy 1-org-1-request constraint if it exists
|
||||
ALTER TABLE tenant_requests DROP CONSTRAINT IF EXISTS tenant_requests_zitadel_org_id_key;
|
||||
@@ -156,8 +158,9 @@ export async function createTenantRequest(
|
||||
`INSERT INTO tenant_requests
|
||||
(zitadel_org_id, zitadel_user_id, company_name, instance_name,
|
||||
contact_name, contact_email, agent_name, soul_md, agents_md,
|
||||
packages, billing_address, billing_notes, encrypted_secrets)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
packages, billing_address, billing_notes, encrypted_secrets,
|
||||
is_personal)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
|
||||
RETURNING *`,
|
||||
[
|
||||
params.zitadelOrgId,
|
||||
@@ -173,6 +176,7 @@ export async function createTenantRequest(
|
||||
JSON.stringify(params.billingAddress),
|
||||
params.billingNotes,
|
||||
params.encryptedSecrets ?? null,
|
||||
params.isPersonal ?? false,
|
||||
]
|
||||
);
|
||||
return mapRow(result.rows[0]);
|
||||
@@ -408,6 +412,7 @@ function mapRow(row: any): TenantRequest {
|
||||
adminNotes: row.admin_notes,
|
||||
tenantName: row.tenant_name,
|
||||
encryptedSecrets: row.encrypted_secrets ?? null,
|
||||
isPersonal: row.is_personal ?? false,
|
||||
createdAt: row.created_at?.toISOString?.() ?? row.created_at,
|
||||
updatedAt: row.updated_at?.toISOString?.() ?? row.updated_at,
|
||||
};
|
||||
|
||||
@@ -140,6 +140,12 @@ export function isPublicEmailDomain(domain: string): boolean {
|
||||
* Look up active tenant_requests whose contact_email shares the given domain.
|
||||
* Active = status NOT IN ('rejected', 'deleted').
|
||||
*
|
||||
* Slice 4: personal-account rows (is_personal = TRUE) are excluded. A
|
||||
* person's personal account doesn't claim the domain on behalf of a
|
||||
* company — alice@acme.ch registering as a personal account must not
|
||||
* block the actual Acme GmbH from registering later. The personal flag
|
||||
* lives on the row itself, set by /api/register at creation time.
|
||||
*
|
||||
* Uses LOWER() on both sides to handle any historical case inconsistency in
|
||||
* stored emails. The pattern '%@<domain>' is anchored so 'acme.ch' does not
|
||||
* match 'notacme.ch' or 'acme.ch.evil.com'.
|
||||
@@ -151,7 +157,8 @@ async function findDuplicateInDb(
|
||||
const result = await pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*) AS count FROM tenant_requests
|
||||
WHERE LOWER(contact_email) LIKE $1
|
||||
AND status NOT IN ('rejected', 'deleted')`,
|
||||
AND status NOT IN ('rejected', 'deleted')
|
||||
AND is_personal = FALSE`,
|
||||
[`%@${domain.toLowerCase()}`]
|
||||
);
|
||||
return Number(result.rows[0]?.count ?? 0) > 0;
|
||||
|
||||
40
src/lib/personal-org.ts
Normal file
40
src/lib/personal-org.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Personal-account helpers.
|
||||
*
|
||||
* Slice 4 establishes the convention that ZITADEL org names for personal
|
||||
* accounts end with the literal " (Personal)" suffix. This file
|
||||
* centralises the suffix and the predicate so both registration (which
|
||||
* sets the suffix) and onboarding (which reads it from the session) use
|
||||
* the same canonical form.
|
||||
*
|
||||
* Why a name suffix and not ZITADEL org metadata?
|
||||
* -----------------------------------------------
|
||||
* 1. The suffix is visible in ZITADEL Console, admin tools, JWT claims,
|
||||
* etc. — useful debugging signal at zero cost.
|
||||
* 2. Customers cannot rename their own org (requires IAM_OWNER, which
|
||||
* only the SA holds), so the suffix is stable for the lifetime of
|
||||
* the org.
|
||||
* 3. No extra ZITADEL API calls at onboarding time to fetch metadata.
|
||||
* 4. No extra portal DB tables.
|
||||
*
|
||||
* The trade-off: an admin who manually renames a personal org via
|
||||
* ZITADEL Console could remove the suffix, after which onboarding
|
||||
* would treat that org as a company. That's a deliberate destructive
|
||||
* action and the worst outcome is a misnamed K8s CR; nothing breaks.
|
||||
*/
|
||||
|
||||
export const PERSONAL_ORG_SUFFIX = " (Personal)";
|
||||
|
||||
/**
|
||||
* Returns true when the given ZITADEL org name marks a personal account.
|
||||
*
|
||||
* The check is exact-suffix match (after trimming). Whitespace inside
|
||||
* the suffix is significant — `" (personal)"` lowercase or `"(Personal)"`
|
||||
* without the leading space are not matches and not personal orgs.
|
||||
*
|
||||
* Pass `session.orgName` from the SessionUser at the call site.
|
||||
*/
|
||||
export function isPersonalOrgName(orgName: string | null | undefined): boolean {
|
||||
if (!orgName) return false;
|
||||
return orgName.trimEnd().endsWith(PERSONAL_ORG_SUFFIX);
|
||||
}
|
||||
Reference in New Issue
Block a user