diff --git a/src/modules/auth/orchest-identity.spec.ts b/src/modules/auth/orchest-identity.spec.ts index f7ec418..abcf98d 100644 --- a/src/modules/auth/orchest-identity.spec.ts +++ b/src/modules/auth/orchest-identity.spec.ts @@ -3,6 +3,9 @@ import { moduleGateSeqid, isModuleAllowed, isAdmin, + tenantOrchestApiHost, + isAllowedOrchestApiHost, + namespaceModule, } from './orchest-identity'; describe('orchest-identity mapping', () => { @@ -31,3 +34,43 @@ describe('orchest-identity mapping', () => { expect(isAdmin([99], [34, 99])).toBe(true); }); }); + +describe('orchest-identity tenant routing', () => { + it('namespaceModule default and values', () => { + expect(namespaceModule(undefined)).toBe('intelli'); + expect(namespaceModule('process')).toBe('process'); + expect(namespaceModule('garbage')).toBe('intelli'); + }); + + it('tenantOrchestApiHost builds the namespace pattern', () => { + expect(tenantOrchestApiHost('acme', 'intelli')).toBe( + 'orchest-api.orchest-intelli-acme.svc.cluster.local', + ); + expect(tenantOrchestApiHost('acme', 'process')).toBe( + 'orchest-api.orchest-process-acme.svc.cluster.local', + ); + }); + + it('tenantOrchestApiHost normalizes a non-slug customer_name', () => { + expect(tenantOrchestApiHost('Acme Corp', 'intelli')).toBe( + 'orchest-api.orchest-intelli-acme-corp.svc.cluster.local', + ); + }); + + it('isAllowedOrchestApiHost guards against malformed values', () => { + expect( + isAllowedOrchestApiHost( + 'orchest-api.orchest-intelli-acme.svc.cluster.local', + ), + ).toBe(true); + expect(isAllowedOrchestApiHost('evil.example.com')).toBe(false); + expect( + isAllowedOrchestApiHost('orchest-api.orchest-intelli-.svc.cluster.local'), + ).toBe(false); + expect( + isAllowedOrchestApiHost( + 'orchest-api.orchest-other-acme.svc.cluster.local', + ), + ).toBe(false); + }); +}); diff --git a/src/modules/auth/orchest-identity.ts b/src/modules/auth/orchest-identity.ts index cfe3248..7a75c14 100644 --- a/src/modules/auth/orchest-identity.ts +++ b/src/modules/auth/orchest-identity.ts @@ -32,3 +32,39 @@ export function isModuleAllowed(perms: number[], gateSeqid: number): boolean { export function isAdmin(perms: number[], adminSeqids: number[]): boolean { return Array.isArray(perms) && perms.some((p) => adminSeqids.includes(p)); } + +/** The `{module}` slug in the tenant namespace pattern; default 'intelli'. */ +export function namespaceModule( + raw: string | undefined, +): 'intelli' | 'process' { + return raw === 'process' ? 'process' : 'intelli'; +} + +/** + * The in-cluster DNS of the calling tenant's orchest-api, from the tenant + * namespace convention `orchest-{module}-{customer_name}`. + * + * `customer_name` is normalized to the namespace slug shape (lowercase, + * non-[a-z0-9-] → '-') so a display-name value still yields a valid host; a + * value that is already a clean slug is unchanged. NOTE: confirm the exact + * prod `customer_name` → namespace mapping against a real token before relying + * on this in production (the `dadosferademo2` tenant is a known exception and + * is unsupported — it is being removed). + */ +export function tenantOrchestApiHost( + customerName: string, + module: string, +): string { + const slug = String(customerName) + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-'); + return `orchest-api.orchest-${module}-${slug}.svc.cluster.local`; +} + +const ORCHEST_API_HOST_RE = + /^orchest-api\.orchest-(intelli|process)-[a-z0-9-]+\.svc\.cluster\.local$/; + +/** Defense in depth: only call an orchest-api host matching the convention. */ +export function isAllowedOrchestApiHost(host: string): boolean { + return ORCHEST_API_HOST_RE.test(host); +}