From 71bd36e271efa948725a117ae7e1d3661268fb17 Mon Sep 17 00:00:00 2001 From: "Claude (product-designer)" Date: Mon, 3 Aug 2026 18:18:23 +0000 Subject: [PATCH] feat(identity): Authentik per-portal brands + per-domain OIDC redirects (FF-EPIC-11 S4) Adds two resumable provisioning steps (authentik_redirect_register, authentik_brand_register) to the portal provisioning pipeline: the redirect step blocks/fails-loud (login must never be silently broken), the brand step is best-effort/cosmetic. Reuses the existing authentik-admin.ts client and custom-domains/authentikRedirect.ts registrar rather than duplicating them, and adds a new authentik/portalBrand.ts Admin-API-backed brand registrar plus a documented (non-live) blueprint template following brand-mendys.yaml. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0127R9Zcw92tmBkxUUuLEB79 --- backend/src/authentik/portalBrand.ts | 144 ++++++++++ ...021_portal_provisioning_authentik_steps.ts | 43 +++ backend/src/services/portalProvisioning.ts | 160 ++++++++++- .../authentik-redirect-registrar.test.ts | 124 +++++++++ backend/tests/portal-authentik-brand.test.ts | 109 ++++++++ backend/tests/portal-provisioning.test.ts | 262 +++++++++++++++++- .../brand-portal-template.yaml | 77 +++++ 7 files changed, 914 insertions(+), 5 deletions(-) create mode 100644 backend/src/authentik/portalBrand.ts create mode 100644 backend/src/migrations/021_portal_provisioning_authentik_steps.ts create mode 100644 backend/tests/authentik-redirect-registrar.test.ts create mode 100644 backend/tests/portal-authentik-brand.test.ts create mode 100644 deploy/helm/fuzefront/authentik/blueprint-templates/brand-portal-template.yaml diff --git a/backend/src/authentik/portalBrand.ts b/backend/src/authentik/portalBrand.ts new file mode 100644 index 00000000..d7fb61c3 --- /dev/null +++ b/backend/src/authentik/portalBrand.ts @@ -0,0 +1,144 @@ +/** + * Per-portal Authentik brand provisioning (FF-EPIC-11-S4 AC2). + * + * Authentik resolves which `authentik_brands.brand` to render for a login / + * enrollment / recovery page by matching the REQUEST HOST against + * `brand.domain` — the same "soft multi-tenancy" mechanism + * `brand-fuseseam.yaml` already relies on for `*.fuzefront.com` (see + * `deploy/helm/fuzefront/authentik/blueprints-mendys/README.md`'s "Why a + * separate instance instead of a brand" section for the authoritative + * description of this mechanism). Every FuzeFront-hosted portal shares the + * SAME Authentik directory/pool (per FF-EPIC-11's account-namespacing note), + * so a per-portal brand is exactly the soft-tenancy primitive Authentik + * offers for "same pool, different login look". + * + * A brand is created ON DEMAND, at portal-provisioning time, for a domain + * that did not exist until an admin called the portal-create API — i.e. it is + * runtime data, not deploy-time data. That is the identical reasoning + * `../custom-domains/authentikRedirect.ts` already documents for why the + * per-domain OIDC redirect URI is mutated through the Admin API rather than a + * blueprint: "blueprints are a static ConfigMap rendered at deploy time and + * these URIs are runtime data". A brand is provisioned the same way here, via + * `POST`/`PATCH /api/v3/core/brands/`. + * + * A STATIC blueprint TEMPLATE is still provided at + * `deploy/helm/fuzefront/authentik/blueprints/brand-portal-template.yaml`, + * following the `brand-mendys.yaml` precedent exactly, for the case a portal + * ever needs a hand-applied/GitOps-rendered brand (e.g. a dedicated-instance + * tenant analogous to MendysRobotics). It documents the intended shape; it is + * NOT applied automatically by anything in this repo (applying a blueprint is + * a cluster operation — FuzeInfra/GitOps territory). The automatic per-portal + * path every ordinary portal goes through is this module, invoked from + * `services/portalProvisioning.ts`. + * + * Idempotent: `ensure()` upserts by `domain` (mirrors the blueprints' own + * `identifiers: { domain }` idempotency key), so calling it again for the + * same domain (a resumed provisioning attempt, or a later branding edit) + * updates the existing brand instead of creating a duplicate. + * + * Never sets `default: true` — that flag is reserved for the platform brand + * (`brand-fuseseam.yaml`'s `fuzefront.com` entry); a per-portal brand is + * selected purely by its own `domain` match, never as the directory-wide + * fallback. + */ + +import axios from 'axios' +import { + AUTHENTIK_TIMEOUT_MS, + buildHeaders, + findAcrossPages, + getAuthentikAdminToken, + getAuthentikBaseUrl, +} from './authentik-admin' + +export interface PortalBrandInput { + /** The domain Authentik matches the login request's Host header against. */ + domain: string + name: string + accent?: string | null + logo?: string | null + favicon?: string | null +} + +export interface PortalBrandRegistrar { + ensure(input: PortalBrandInput): Promise +} + +interface AuthentikBrand { + brand_uuid: string + domain: string +} + +/** + * A minimal accent-color override, layered on top of whatever the platform + * brand already ships (Authentik applies exactly one brand's CSS per + * request, so this re-declares the handful of selectors the platform brand + * themes rather than assuming inheritance). Undefined when no accent was + * supplied, so a portal with no branding gets no custom CSS at all — the + * Authentik-default look, not a broken half-themed one. + */ +function customCss(accent: string | null | undefined): string | undefined { + if (!accent) return undefined + const safeAccent = /^#[0-9a-fA-F]{3,8}$/.test(accent) ? accent : undefined + if (!safeAccent) return undefined + return [ + ':root { --fuse-accent: ' + safeAccent + '; }', + '.pf-v5-c-login__main::before { background: ' + safeAccent + ' !important; }', + '.pf-v5-c-button.pf-m-primary, button[type="submit"] { background: ' + + safeAccent + + ' !important; }', + '.pf-v5-c-form-control:focus, input:focus { border-color: ' + + safeAccent + + ' !important; }', + 'a, .pf-v5-c-button.pf-m-link { color: ' + safeAccent + ' !important; }', + ].join('\n') +} + +/** + * Admin-API-backed registrar. Returns `null` when Authentik is not configured + * (no admin token) — mirrors `createAuthentikRedirectRegistrar`'s degrade + * contract exactly, so a deployment without Authentik wired up gets "no + * per-portal brand" rather than a crash. + */ +export function createAuthentikBrandRegistrar(): PortalBrandRegistrar | null { + const token = getAuthentikAdminToken() + if (!token) return null + + const baseUrl = getAuthentikBaseUrl() + const headers = buildHeaders(token) + + return { + async ensure(input: PortalBrandInput): Promise { + const domain = input.domain.trim().toLowerCase() + const attrs: Record = { + domain, + branding_title: input.name, + default: false, + } + const css = customCss(input.accent) + if (css) attrs.branding_custom_css = css + if (input.logo) attrs.branding_logo = input.logo + if (input.favicon) attrs.branding_favicon = input.favicon + + const existing = await findAcrossPages( + `${baseUrl}/api/v3/core/brands/`, + { domain }, + headers, + b => b.domain === domain + ) + + if (existing) { + await axios.patch(`${baseUrl}/api/v3/core/brands/${existing.brand_uuid}/`, attrs, { + headers, + timeout: AUTHENTIK_TIMEOUT_MS, + }) + return + } + + await axios.post(`${baseUrl}/api/v3/core/brands/`, attrs, { + headers, + timeout: AUTHENTIK_TIMEOUT_MS, + }) + }, + } +} diff --git a/backend/src/migrations/021_portal_provisioning_authentik_steps.ts b/backend/src/migrations/021_portal_provisioning_authentik_steps.ts new file mode 100644 index 00000000..36f3baf9 --- /dev/null +++ b/backend/src/migrations/021_portal_provisioning_authentik_steps.ts @@ -0,0 +1,43 @@ +import { Knex } from 'knex' + +/** + * FF-EPIC-11-S4 — adds the two Authentik steps to + * `portal_provisioning_step_enum`: + * + * - `authentik_redirect_register` — per-domain OIDC redirect URI + * registration (AC1/AC3/AC4). An INFRA step (blocking, fail-loud): a + * portal must not reach `provisioned-pending-invite` with an + * unregistered redirect URI, because that is a silently broken login, + * exactly the failure mode AC4 forbids. + * - `authentik_brand_register` — per-portal Authentik brand for login + * theming (AC2). NOT an infra step — purely cosmetic, so (like + * `owner_invite`) its failure is recorded but never blocks/regresses the + * portal's status nor fails the overall provisioning call. + * + * Adding a step to `services/portalProvisioning.ts`'s + * `PORTAL_PROVISIONING_STEPS` without extending this enum makes + * `ensureStepRows()` fail on every provision/resume with "invalid input + * value for enum portal_provisioning_step_enum" — same failure mode + * `016_provisioning_steps_rebac.ts` documents for the sibling + * `organization_provisioning` table, and the same fix shape. + */ + +// ALTER TYPE ... ADD VALUE cannot run inside a transaction block, and knex +// wraps migrations in a transaction by default — same reasoning as +// migration 016. +export const config = { transaction: false } + +export async function up(knex: Knex): Promise { + await knex.raw(` + ALTER TYPE portal_provisioning_step_enum ADD VALUE IF NOT EXISTS 'authentik_redirect_register' + `) + await knex.raw(` + ALTER TYPE portal_provisioning_step_enum ADD VALUE IF NOT EXISTS 'authentik_brand_register' + `) +} + +export async function down(_knex: Knex): Promise { + // Postgres has no ALTER TYPE ... DROP VALUE; the members are left in + // place, exactly as migration 016 leaves its additions on + // provisioning_step_enum. +} diff --git a/backend/src/services/portalProvisioning.ts b/backend/src/services/portalProvisioning.ts index 0b4bec37..28aa704a 100644 --- a/backend/src/services/portalProvisioning.ts +++ b/backend/src/services/portalProvisioning.ts @@ -20,11 +20,17 @@ import { PortalIdentityPolicy, BillingMode, } from '../repositories/portalRepository' +import { isMultiTenantPortalsEnabled } from '../utils/portalFlag' +import { createAuthentikRedirectRegistrar } from '../custom-domains/authentikRedirect' +import type { RedirectUriRegistrar } from '../custom-domains/customHostnameService' +import { createAuthentikBrandRegistrar } from '../authentik/portalBrand' +import type { PortalBrandRegistrar } from '../authentik/portalBrand' /** * FF-EPIC-09-S2 — resumable master-admin portal provisioning pipeline: * org -> Permit tenant -> Organization ReBAC instance/parent link -> portals - * row -> default subdomain -> owner invite. + * row -> default subdomain -> Authentik redirect URI -> Authentik brand -> + * owner invite. * * Mirrors `services/organizationProvisioning.ts`'s reconcile pattern * (idempotent, dependency-ordered step log + a Postgres advisory lock) rather @@ -43,6 +49,32 @@ import { * start completely over. `SlugTakenError` is the only intentional throw, and * it is only ever raised BEFORE any row in this transaction is touched, so * rolling back an empty transaction is harmless. + * + * FF-EPIC-11-S4 adds two Authentik steps, treated very differently: + * + * - `authentik_redirect_register` (AC1/AC3/AC4) is an INFRA step (same + * blocking/fail-loud contract as every step above it) — it registers the + * OIDC redirect URI for EVERY row currently in `portal_domains` for this + * portal via the existing `RedirectUriRegistrar` contract + * (`custom-domains/authentikRedirect.ts`, reused verbatim). Blocking is + * deliberate: an unregistered redirect URI is a silently broken login, + * which is exactly the failure mode AC4 forbids, so this step must + * succeed before the portal is allowed to reach + * `provisioned-pending-invite`. + * - `authentik_brand_register` (AC2) registers the portal's Authentik + * brand (login-page theming) via `authentik/portalBrand.ts`. Unlike the + * redirect step, this is treated exactly like `owner_invite` — recorded, + * independently retryable, but its failure never blocks or regresses the + * portal's status nor fails the overall `provisionPortal()` call, because + * losing branding is cosmetic, not a broken login. + * + * Both steps are no-ops (recorded `done`, no Authentik call made) while + * `fuzefront.platform.multi-tenant-portals` is OFF — this pipeline already + * runs regardless of that flag (see `index.ts`'s `ensureRootPortal` comment: + * "runs regardless of the multi-tenant-portals flag ... creates dormant rows + * nothing reads while the flag is off"), and these two steps follow the same + * contract rather than making a live Authentik call for a portal nothing can + * reach yet. */ export const PORTAL_PROVISIONING_STEPS = [ @@ -52,6 +84,8 @@ export const PORTAL_PROVISIONING_STEPS = [ 'permit_org_parent', 'portal_row_create', 'default_domain_create', + 'authentik_redirect_register', + 'authentik_brand_register', 'owner_invite', ] as const @@ -68,6 +102,10 @@ export interface PortalProvisioningDeps { db: Knex permit: PortalProvisioningPermitClient publish: EventPublisher + /** FF-EPIC-11-S4 AC1 — registers a domain's OIDC redirect URI in Authentik. */ + redirectUris: RedirectUriRegistrar + /** FF-EPIC-11-S4 AC2 — creates/updates the portal's Authentik login brand. */ + brandRegistrar: PortalBrandRegistrar } export const defaultPortalPermitClient: PortalProvisioningPermitClient = { @@ -84,11 +122,45 @@ export const defaultPortalPermitClient: PortalProvisioningPermitClient = { }, } +/** + * Falls back to a no-op when Authentik is not configured for this + * deployment (no `AUTHENTIK_ADMIN_TOKEN`) — mirrors + * `custom-domains/authentikRedirect.ts`'s own degrade contract + * (`createAuthentikRedirectRegistrar` returning `null`) so a deployment + * without Authentik wired up gets "no redirect registered" instead of a + * crash on every portal create. + */ +function defaultRedirectUriRegistrar(): RedirectUriRegistrar { + const registrar = createAuthentikRedirectRegistrar() + if (registrar) return registrar + return { + async register() { + /* Authentik not configured — see doc comment above. */ + }, + async deregister() { + /* Authentik not configured — see doc comment above. */ + }, + } +} + +/** Same degrade-to-no-op contract as {@link defaultRedirectUriRegistrar}. */ +function defaultBrandRegistrar(): PortalBrandRegistrar { + const registrar = createAuthentikBrandRegistrar() + if (registrar) return registrar + return { + async ensure() { + /* Authentik not configured — see defaultRedirectUriRegistrar's doc. */ + }, + } +} + function getDeps(overrides?: Partial): PortalProvisioningDeps { return { db: overrides?.db ?? defaultDb, permit: overrides?.permit ?? defaultPortalPermitClient, publish: overrides?.publish ?? defaultEventPublisher, + redirectUris: overrides?.redirectUris ?? defaultRedirectUriRegistrar(), + brandRegistrar: overrides?.brandRegistrar ?? defaultBrandRegistrar(), } } @@ -144,6 +216,32 @@ const DEFAULT_IDENTITY_POLICY: PortalIdentityPolicy = { ssoProviders: [], } +/** + * Defensive parse of `portals.branding` for the `authentik_brand_register` + * step. The column is `jsonb`, written via `JSON.stringify` in + * `portal_row_create` above, but knex/pg's driver may hand it back either as + * an already-parsed object or as a raw string depending on connection pool + * type-parser config — same ambiguity `portalRepository.ts`'s + * `parseJsonColumnWithDefaults` guards against. Falls back to + * `DEFAULT_BRANDING(name)` on anything unparseable so a malformed value never + * throws out of this best-effort step. + */ +function parseBrandingColumn(value: unknown, name: string): PortalBranding { + const fallback = DEFAULT_BRANDING(name) + if (!value) return fallback + if (typeof value === 'string') { + try { + return { ...fallback, ...JSON.parse(value) } + } catch { + return fallback + } + } + if (typeof value === 'object') { + return { ...fallback, ...(value as Partial) } + } + return fallback +} + async function ensureStepRows(qb: Knex | Knex.Transaction, slug: string): Promise { const rows = await qb('portal_provisioning').where({ slug }) const present = new Set(rows.map((r: any) => r.step)) @@ -245,7 +343,14 @@ export async function provisionPortal( }) } - const INFRA_STEPS = PORTAL_PROVISIONING_STEPS.filter(s => s !== 'owner_invite') + // `authentik_brand_register` is excluded here for the same reason + // `owner_invite` is: cosmetic/independently-retryable, handled in its own + // best-effort block below the completion checkpoint, never blocking. + // `authentik_redirect_register` stays IN — see the module doc comment + // (AC4 fail-loud contract). + const INFRA_STEPS = PORTAL_PROVISIONING_STEPS.filter( + s => s !== 'owner_invite' && s !== 'authentik_brand_register' + ) let failedStep: PortalProvisioningStep | undefined let failureMessage: string | undefined @@ -330,6 +435,25 @@ export async function provisionPortal( .onConflict('domain') .ignore() break + case 'authentik_redirect_register': { + // No-op while the master flag is off — see the module doc + // comment. Re-evaluated per attempt (not cached), same as every + // other flag read in this codebase. + if (await isMultiTenantPortalsEnabled()) { + // AC3 — registers EVERY domain currently on this portal, not + // just the primary one, so a portal with more than one + // `portal_domains` row (e.g. a resumed run that now also has a + // path/custom domain) gets a correct, independent redirect URI + // for each. `register()` itself is idempotent (dedupes by + // exact URL), so re-registering an already-registered domain + // here is a safe no-op. + const domainRows = await trx('portal_domains').where({ portal_id: portalId }) + for (const domainRow of domainRows) { + await deps.redirectUris.register(domainRow.domain) + } + } + break + } } await markDone(step) @@ -374,6 +498,38 @@ export async function provisionPortal( justTransitioned = true } + // Authentik brand (AC2) — independently retryable, purely cosmetic + // (branded login theming), so unlike `authentik_redirect_register` a + // failure here must NOT block/regress the portal's status nor fail the + // overall create call. AC4's fail-loud contract is scoped to the + // redirect-URI step specifically, because that failure breaks login + // outright; losing branding does not. + const brandRow = stepRows['authentik_brand_register'] + if (brandRow?.status !== 'done') { + try { + if (portalRowBeforeInvite && (await isMultiTenantPortalsEnabled())) { + const domainRows = await trx('portal_domains').where({ portal_id: portalId }) + const primaryDomain = + domainRows.find((d: any) => d.is_primary)?.domain ?? domainRows[0]?.domain + if (primaryDomain) { + const branding = parseBrandingColumn(portalRowBeforeInvite.branding, input.name) + await deps.brandRegistrar.ensure({ + domain: primaryDomain, + name: branding.name, + accent: branding.accent ?? null, + logo: branding.logo ?? null, + favicon: branding.favicon ?? null, + }) + } + } + await markDone('authentik_brand_register') + } catch (error: any) { + await markFailed('authentik_brand_register', error) + // Swallow — never fail the create call; cosmetic-only (see comment + // above). + } + } + // Owner invite — independently retryable; a failure here must NOT // regress the portal's status nor fail the overall create call (AC4). const inviteRow = stepRows['owner_invite'] diff --git a/backend/tests/authentik-redirect-registrar.test.ts b/backend/tests/authentik-redirect-registrar.test.ts new file mode 100644 index 00000000..4f11ec8b --- /dev/null +++ b/backend/tests/authentik-redirect-registrar.test.ts @@ -0,0 +1,124 @@ +/** + * authentik-redirect-registrar.test.ts + * + * Unit tests for `src/custom-domains/authentikRedirect.ts`, the module FF-EPIC-11 + * S4's `authentik_redirect_register` provisioning step reuses verbatim. No + * tests previously existed for it; these cover the multi-domain correctness + * (FF-EPIC-11-S4 AC3) and idempotent-registration behavior the provisioning + * step depends on. Authentik is mocked at the axios layer, same pattern as + * `tests/provision-a2a-clients.test.ts`. + */ + +jest.mock('axios', () => { + const actual = jest.requireActual('axios') + return { + ...actual, + post: jest.fn(), + patch: jest.fn(), + get: jest.fn(), + isAxiosError: actual.isAxiosError, + } +}) + +import axios from 'axios' +import { createAuthentikRedirectRegistrar, callbackUri } from '../src/custom-domains/authentikRedirect' + +const mockedGet = axios.get as jest.MockedFunction +const mockedPatch = axios.patch as jest.MockedFunction + +const PROVIDER_PK = 42 + +beforeEach(() => { + jest.clearAllMocks() + process.env.AUTHENTIK_ADMIN_TOKEN = 'admin-token' + process.env.AUTHENTIK_BASE_URL = 'http://authentik.test:9000' +}) + +afterEach(() => { + delete process.env.AUTHENTIK_ADMIN_TOKEN + delete process.env.AUTHENTIK_BASE_URL +}) + +/** Wires GET so `loadProvider()` always sees the CURRENT `redirectUris` set. */ +function wireProvider(redirectUris: Array<{ matching_mode: string; url: string }>): void { + mockedGet.mockImplementation(async (url: string) => { + if (url.includes('/providers/oauth2/')) { + return { + data: { + results: [{ pk: PROVIDER_PK, name: 'FuzeFront', redirect_uris: redirectUris }], + pagination: { next: 0 }, + }, + } as any + } + return { data: { results: [], pagination: { next: 0 } } } as any + }) +} + +describe('createAuthentikRedirectRegistrar', () => { + it('returns null when AUTHENTIK_ADMIN_TOKEN is not configured', () => { + delete process.env.AUTHENTIK_ADMIN_TOKEN + expect(createAuthentikRedirectRegistrar()).toBeNull() + }) + + it('AC3 — registers a distinct, correct redirect URI for each of several domains, none clobbering another', async () => { + // Simulated provider state, updated after each PATCH so successive + // register() calls see the accumulated list — same read-modify-write + // contract the real Authentik Admin API has. + let state: Array<{ matching_mode: string; url: string }> = [] + wireProvider(state) + mockedGet.mockImplementation(async () => ({ + data: { results: [{ pk: PROVIDER_PK, name: 'FuzeFront', redirect_uris: state }], pagination: { next: 0 } }, + } as any)) + mockedPatch.mockImplementation(async (_url: string, body: any) => { + state = body.redirect_uris + return { data: {} } as any + }) + + const registrar = createAuthentikRedirectRegistrar()! + await registrar.register('acme.fuzefront.com') + await registrar.register('custom.acmecorp.example.com') + + expect(mockedPatch).toHaveBeenCalledTimes(2) + expect(state).toEqual( + expect.arrayContaining([ + { matching_mode: 'strict', url: callbackUri('acme.fuzefront.com') }, + { matching_mode: 'strict', url: callbackUri('custom.acmecorp.example.com') }, + ]) + ) + expect(state).toHaveLength(2) + // No cross-domain mismatch: each URI carries exactly its own domain. + expect(callbackUri('acme.fuzefront.com')).not.toBe(callbackUri('custom.acmecorp.example.com')) + }) + + it('idempotent — re-registering the same URI issues no PATCH (a genuine no-op)', async () => { + const existing = [{ matching_mode: 'strict', url: callbackUri('acme.fuzefront.com') }] + wireProvider(existing) + + const registrar = createAuthentikRedirectRegistrar()! + await registrar.register('acme.fuzefront.com') + + expect(mockedPatch).not.toHaveBeenCalled() + }) + + it('preserves existing entries (e.g. the static apex host) when adding a new one', async () => { + let state = [{ matching_mode: 'strict', url: 'https://app.fuzefront.com/api/auth/oidc/callback' }] + mockedGet.mockImplementation(async () => ({ + data: { results: [{ pk: PROVIDER_PK, name: 'FuzeFront', redirect_uris: state }], pagination: { next: 0 } }, + } as any)) + mockedPatch.mockImplementation(async (_url: string, body: any) => { + state = body.redirect_uris + return { data: {} } as any + }) + + const registrar = createAuthentikRedirectRegistrar()! + await registrar.register('acme.fuzefront.com') + + expect(state).toEqual( + expect.arrayContaining([ + { matching_mode: 'strict', url: 'https://app.fuzefront.com/api/auth/oidc/callback' }, + { matching_mode: 'strict', url: callbackUri('acme.fuzefront.com') }, + ]) + ) + expect(state).toHaveLength(2) + }) +}) diff --git a/backend/tests/portal-authentik-brand.test.ts b/backend/tests/portal-authentik-brand.test.ts new file mode 100644 index 00000000..931aae04 --- /dev/null +++ b/backend/tests/portal-authentik-brand.test.ts @@ -0,0 +1,109 @@ +/** + * portal-authentik-brand.test.ts + * + * Unit tests for `src/authentik/portalBrand.ts` (FF-EPIC-11-S4 AC2). + * Authentik is mocked at the axios layer, same pattern as + * `tests/provision-a2a-clients.test.ts` — no live Authentik dependency. + */ + +jest.mock('axios', () => { + const actual = jest.requireActual('axios') + return { + ...actual, + post: jest.fn(), + patch: jest.fn(), + get: jest.fn(), + isAxiosError: actual.isAxiosError, + } +}) + +import axios from 'axios' +import { createAuthentikBrandRegistrar } from '../src/authentik/portalBrand' + +const mockedGet = axios.get as jest.MockedFunction +const mockedPost = axios.post as jest.MockedFunction +const mockedPatch = axios.patch as jest.MockedFunction + +const EMPTY_PAGE = { data: { results: [], pagination: { next: 0 } } } + +beforeEach(() => { + jest.clearAllMocks() + process.env.AUTHENTIK_ADMIN_TOKEN = 'admin-token' + process.env.AUTHENTIK_BASE_URL = 'http://authentik.test:9000' +}) + +afterEach(() => { + delete process.env.AUTHENTIK_ADMIN_TOKEN + delete process.env.AUTHENTIK_BASE_URL +}) + +describe('createAuthentikBrandRegistrar', () => { + it('returns null when AUTHENTIK_ADMIN_TOKEN is not configured', () => { + delete process.env.AUTHENTIK_ADMIN_TOKEN + expect(createAuthentikBrandRegistrar()).toBeNull() + }) + + it('creates a brand for a new domain with default:false and no self-selection as the platform default', async () => { + mockedGet.mockResolvedValueOnce(EMPTY_PAGE as any) + mockedPost.mockResolvedValueOnce({ data: { brand_uuid: 'brand-1' } } as any) + + const registrar = createAuthentikBrandRegistrar()! + await registrar.ensure({ + domain: 'acme.fuzefront.com', + name: 'Acme Corp', + accent: '#ff0000', + }) + + expect(mockedPost).toHaveBeenCalledTimes(1) + const [url, body] = mockedPost.mock.calls[0] + expect(url).toBe('http://authentik.test:9000/api/v3/core/brands/') + expect(body).toMatchObject({ + domain: 'acme.fuzefront.com', + branding_title: 'Acme Corp', + default: false, + }) + expect((body as any).branding_custom_css).toContain('#ff0000') + }) + + it('is idempotent — updates the existing brand by domain instead of creating a duplicate', async () => { + mockedGet.mockResolvedValueOnce({ + data: { results: [{ brand_uuid: 'brand-existing', domain: 'acme.fuzefront.com' }], pagination: { next: 0 } }, + } as any) + mockedPatch.mockResolvedValueOnce({ data: {} } as any) + + const registrar = createAuthentikBrandRegistrar()! + await registrar.ensure({ domain: 'acme.fuzefront.com', name: 'Acme Corp Renamed' }) + + expect(mockedPost).not.toHaveBeenCalled() + expect(mockedPatch).toHaveBeenCalledTimes(1) + const [url, body] = mockedPatch.mock.calls[0] + expect(url).toBe('http://authentik.test:9000/api/v3/core/brands/brand-existing/') + expect(body).toMatchObject({ branding_title: 'Acme Corp Renamed', default: false }) + }) + + it('omits branding_custom_css when no accent is supplied', async () => { + mockedGet.mockResolvedValueOnce(EMPTY_PAGE as any) + mockedPost.mockResolvedValueOnce({ data: { brand_uuid: 'brand-2' } } as any) + + const registrar = createAuthentikBrandRegistrar()! + await registrar.ensure({ domain: 'plain.fuzefront.com', name: 'Plain Co' }) + + const [, body] = mockedPost.mock.calls[0] + expect((body as any).branding_custom_css).toBeUndefined() + }) + + it('ignores a malformed accent value rather than injecting it unsanitized', async () => { + mockedGet.mockResolvedValueOnce(EMPTY_PAGE as any) + mockedPost.mockResolvedValueOnce({ data: { brand_uuid: 'brand-3' } } as any) + + const registrar = createAuthentikBrandRegistrar()! + await registrar.ensure({ + domain: 'evil.fuzefront.com', + name: 'Evil Co', + accent: '', + }) + + const [, body] = mockedPost.mock.calls[0] + expect((body as any).branding_custom_css).toBeUndefined() + }) +}) diff --git a/backend/tests/portal-provisioning.test.ts b/backend/tests/portal-provisioning.test.ts index 99216415..122655de 100644 --- a/backend/tests/portal-provisioning.test.ts +++ b/backend/tests/portal-provisioning.test.ts @@ -37,6 +37,8 @@ import { } from '../src/services/portalProvisioning' import { ROOT_ORG_ID } from '../src/migrations/015_seed_root_platform_organization' import { createAdminPortalStore } from '../src/routes/adminPortals' +import { callbackUri } from '../src/custom-domains/authentikRedirect' +import * as portalFlagModule from '../src/utils/portalFlag' // ---- fakes ------------------------------------------------------------- @@ -65,6 +67,39 @@ function makeFakePermit( } as any } +/** + * FF-EPIC-11-S4 — fake `RedirectUriRegistrar`. `onRegister` (if supplied) runs + * BEFORE the call is recorded, so a test that makes it throw observes zero + * recorded calls for that attempt — matching the real registrar's behavior of + * not mutating state on a failed API call. + */ +function makeFakeRedirectRegistrar(onRegister?: (domain: string) => void | Promise) { + const calls: string[] = [] + return { + calls, + async register(domain: string) { + if (onRegister) await onRegister(domain) + calls.push(domain) + }, + async deregister() { + /* not exercised by provisioning */ + }, + } +} + +/** FF-EPIC-11-S4 — fake `PortalBrandRegistrar`. Same before/throw contract as + * {@link makeFakeRedirectRegistrar}. */ +function makeFakeBrandRegistrar(onEnsure?: (input: any) => void | Promise) { + const calls: any[] = [] + return { + calls, + async ensure(input: any) { + if (onEnsure) await onEnsure(input) + calls.push(input) + }, + } +} + function makeFakePublisher() { const emails: any[] = [] const portalCreatedEvents: any[] = [] @@ -83,12 +118,41 @@ function makeFakePublisher() { } } +// FF-EPIC-11-S4 — `fuzefront.platform.multi-tenant-portals` gates the two new +// Authentik steps (see portalProvisioning.ts's module doc). Defaults ON here +// so the existing "wired to the real pipeline" happy-path assertions below +// keep exercising the new steps' on-path; the dedicated flag-off describe +// block flips it to false for its own tests and restores true afterward — +// same convention as tests/portal-scoped-invitations.test.ts. +let multiTenantPortalsEnabled = true + beforeAll(() => { initializeDatabaseConnection() + jest + .spyOn(portalFlagModule, 'isMultiTenantPortalsEnabled') + .mockImplementation(async () => multiTenantPortalsEnabled) +}) + +afterEach(() => { + multiTenantPortalsEnabled = true + jest + .spyOn(portalFlagModule, 'isMultiTenantPortalsEnabled') + .mockImplementation(async () => multiTenantPortalsEnabled) }) -function deps(permit: any, publish: any): Partial { - return { db, permit, publish } +function deps( + permit: any, + publish: any, + extra: Partial = {} +): Partial { + return { + db, + permit, + publish, + redirectUris: extra.redirectUris ?? makeFakeRedirectRegistrar(), + brandRegistrar: extra.brandRegistrar ?? makeFakeBrandRegistrar(), + ...extra, + } } async function createUser(): Promise { @@ -125,9 +189,15 @@ describe('provisionPortal — happy path', () => { const actorId = await createUser() const permit = makeFakePermit() const { publisher, emails, portalCreatedEvents } = makeFakePublisher() + const redirectRegistrar = makeFakeRedirectRegistrar() + const brandRegistrar = makeFakeBrandRegistrar() const input = makeInput() - const result = await provisionPortal(input, actorId, deps(permit, publisher)) + const result = await provisionPortal( + input, + actorId, + deps(permit, publisher, { redirectUris: redirectRegistrar, brandRegistrar }) + ) expect(result.ok).toBe(true) expect(result.resumed).toBe(false) @@ -183,6 +253,18 @@ describe('provisionPortal — happy path', () => { ).first() expect(outboxRow).toBeTruthy() + // FF-EPIC-11-S4 AC1 — the default subdomain's OIDC redirect URI is + // registered automatically, no manual step. + expect(redirectRegistrar.calls).toEqual([`${input.slug}.fuzefront.com`]) + + // FF-EPIC-11-S4 AC2 — the portal's Authentik brand is created for the + // same domain, themed from its (default) branding. + expect(brandRegistrar.calls).toHaveLength(1) + expect(brandRegistrar.calls[0]).toMatchObject({ + domain: `${input.slug}.fuzefront.com`, + name: input.name, + }) + // Every step recorded done. const steps = await db('portal_provisioning').where({ slug: input.slug }) expect(steps).toHaveLength(PORTAL_PROVISIONING_STEPS.length) @@ -350,6 +432,180 @@ describe('provisionPortal — AC4: owner-invite failure never regresses status, }) }) +describe('provisionPortal — FF-EPIC-11-S4 AC4: redirect-registration failure fails loud (not a silent success)', () => { + it('records the step failed, does not transition the portal, then succeeds exactly once on resume', async () => { + const actorId = await createUser() + const { publisher } = makeFakePublisher() + const input = makeInput() + + let failRegister = true + const redirectRegistrar = makeFakeRedirectRegistrar(() => { + if (failRegister) throw new Error('authentik outage 503') + }) + + const first = await provisionPortal( + input, + actorId, + deps(makeFakePermit(), publisher, { redirectUris: redirectRegistrar }) + ) + + expect(first.ok).toBe(false) + expect(first.failedStep).toBe('authentik_redirect_register') + // Fail-loud: the portal row exists (created in an earlier step) but was + // NEVER transitioned past 'provisioning' — login is never silently + // broken by a portal that looks ready but has no registered redirect URI. + expect(first.portal).toBeTruthy() + expect(first.portal!.status).toBe('provisioning') + expect(redirectRegistrar.calls).toEqual([]) + + const stepRow = await db('portal_provisioning') + .where({ slug: input.slug, step: 'authentik_redirect_register' }) + .first() + expect(stepRow.status).toBe('failed') + expect(stepRow.last_error).toContain('authentik outage 503') + + // Fix the outage and resume. + failRegister = false + const second = await provisionPortal( + input, + actorId, + deps(makeFakePermit(), publisher, { redirectUris: redirectRegistrar }) + ) + + expect(second.ok).toBe(true) + expect(second.resumed).toBe(true) + expect(second.portal!.status).toBe('provisioned-pending-invite') + expect(redirectRegistrar.calls).toEqual([`${input.slug}.fuzefront.com`]) + + const resolvedStep = await db('portal_provisioning') + .where({ slug: input.slug, step: 'authentik_redirect_register' }) + .first() + expect(resolvedStep.status).toBe('done') + }) +}) + +describe('provisionPortal — FF-EPIC-11-S4 AC3: multi-domain correctness', () => { + it('registers a distinct, correct redirect URI for every domain on the portal', async () => { + const actorId = await createUser() + const { publisher } = makeFakePublisher() + + // Force the FIRST attempt to fail at the redirect step (after + // `default_domain_create` has already created the default subdomain and + // `portalId` is known, but BEFORE the completion checkpoint) — this is + // the pipeline's normal AC2 resumable-failure window, and it is the only + // way to legitimately add a second `portal_domains` row and have the + // step re-run: once the portal reaches `provisioned-pending-invite` a + // fresh `provisionPortal()` call for the same slug is a genuine + // duplicate (`SlugTakenError`), not a resume. + let failRegister = true + const redirectRegistrar = makeFakeRedirectRegistrar(() => { + if (failRegister) throw new Error('authentik outage 503') + }) + const input = makeInput() + + const first = await provisionPortal( + input, + actorId, + deps(makeFakePermit(), publisher, { redirectUris: redirectRegistrar }) + ) + expect(first.ok).toBe(false) + expect(first.failedStep).toBe('authentik_redirect_register') + expect(first.portal).toBeTruthy() + + // Simulate a later custom domain landing on the SAME portal (FF-EPIC-16) + // before the step ever succeeded. + await db('portal_domains').insert({ + portal_id: first.portal!.id, + domain: 'custom.acmecorp.example.com', + kind: 'custom', + is_primary: false, + verification_status: 'verified', + tls_status: 'issued', + }) + + failRegister = false + const second = await provisionPortal( + input, + actorId, + deps(makeFakePermit(), publisher, { redirectUris: redirectRegistrar }) + ) + + expect(second.ok).toBe(true) + expect(second.resumed).toBe(true) + expect(redirectRegistrar.calls.sort()).toEqual( + [`${input.slug}.fuzefront.com`, 'custom.acmecorp.example.com'].sort() + ) + // Each domain's own callback URI is independent — no cross-domain mismatch. + expect(callbackUri(`${input.slug}.fuzefront.com`)).not.toBe( + callbackUri('custom.acmecorp.example.com') + ) + }) +}) + +describe('provisionPortal — FF-EPIC-11-S4: flag-off leaves provisioning unchanged', () => { + it('registers no redirect URI / brand and still completes normally when multi-tenant-portals is OFF', async () => { + multiTenantPortalsEnabled = false + const actorId = await createUser() + const permit = makeFakePermit() + const { publisher } = makeFakePublisher() + const redirectRegistrar = makeFakeRedirectRegistrar() + const brandRegistrar = makeFakeBrandRegistrar() + const input = makeInput() + + const result = await provisionPortal( + input, + actorId, + deps(permit, publisher, { redirectUris: redirectRegistrar, brandRegistrar }) + ) + + expect(result.ok).toBe(true) + expect(result.portal!.status).toBe('provisioned-pending-invite') + expect(redirectRegistrar.calls).toEqual([]) + expect(brandRegistrar.calls).toEqual([]) + + // Steps are still recorded done (no-op, not skipped/dangling). + const steps = await db('portal_provisioning').where({ slug: input.slug }) + expect( + steps.find((s: any) => s.step === 'authentik_redirect_register')?.status + ).toBe('done') + expect( + steps.find((s: any) => s.step === 'authentik_brand_register')?.status + ).toBe('done') + }) +}) + +describe('provisionPortal — FF-EPIC-11-S4 AC2: Authentik brand registration is best-effort', () => { + it('records a brand-registration failure without blocking or regressing the portal', async () => { + const actorId = await createUser() + const permit = makeFakePermit() + const { publisher, portalCreatedEvents } = makeFakePublisher() + const brandRegistrar = makeFakeBrandRegistrar(() => { + throw new Error('authentik brands API outage') + }) + const input = makeInput() + + const result = await provisionPortal( + input, + actorId, + deps(permit, publisher, { brandRegistrar }) + ) + + // Unlike the redirect step, a brand failure never fails the call. + expect(result.ok).toBe(true) + expect(result.portal!.status).toBe('provisioned-pending-invite') + expect(brandRegistrar.calls).toEqual([]) + + const brandStep = await db('portal_provisioning') + .where({ slug: input.slug, step: 'authentik_brand_register' }) + .first() + expect(brandStep.status).toBe('failed') + expect(brandStep.last_error).toContain('authentik brands API outage') + + // portal.created still fires — a cosmetic brand failure never blocks it. + expect(portalCreatedEvents).toHaveLength(1) + }) +}) + describe('provisionPortal — genuine duplicate slug', () => { it('rejects a fresh create for a slug that already resolved to a non-provisioning portal', async () => { const actorId = await createUser() diff --git a/deploy/helm/fuzefront/authentik/blueprint-templates/brand-portal-template.yaml b/deploy/helm/fuzefront/authentik/blueprint-templates/brand-portal-template.yaml new file mode 100644 index 00000000..271e9687 --- /dev/null +++ b/deploy/helm/fuzefront/authentik/blueprint-templates/brand-portal-template.yaml @@ -0,0 +1,77 @@ +# Authentik Blueprint TEMPLATE — per-portal brand (FF-EPIC-11-S4 AC2) +# +# THIS FILE IS NOT APPLIED BY ANYTHING, AND MUST NEVER LIVE UNDER +# `../blueprints/`. `templates/authentik-blueprints.yaml` builds the live +# ConfigMap via `.Files.Glob "authentik/blueprints/*.yaml"` and Authentik's +# worker auto-applies EVERY file that glob picks up on pod start — so if this +# template (with its literal, un-substituted `{{PLACEHOLDER}}` tokens) sat in +# that directory, the next deploy would try to create a real brand with a +# domain literally named "{{PORTAL_DOMAIN}}". This `blueprint-templates/` +# sibling directory is deliberately outside that glob for exactly that +# reason — do not move this file into `../blueprints/`, ever, without also +# rendering out its placeholders first. +# +# It documents the shape of a per-portal `authentik_brands.brand` — +# following the `brand-mendys.yaml` precedent exactly (`state: present`, +# `identifiers: { domain }`, `default: false`) — for the rare case a portal +# needs a hand-applied / GitOps-rendered brand (e.g. a dedicated-instance +# tenant analogous to MendysRobotics, or a break-glass fix while the live +# Admin API is down). +# +# The path EVERY ordinary portal actually goes through is automatic and +# runtime: `backend/src/authentik/portalBrand.ts`'s `createAuthentikBrandRegistrar`, +# invoked from `backend/src/services/portalProvisioning.ts`'s +# `authentik_brand_register` step at portal-creation time. That module talks +# to the SAME `POST`/`PATCH /api/v3/core/brands/` Admin API a blueprint apply +# would ultimately drive — it is the "provisioning artifact" alternative to a +# static file, chosen because portals (and therefore their domains/branding) +# are created on demand through the admin API, long after this chart last +# deployed. A blueprint is a ConfigMap rendered once at Helm-chart deploy +# time; it cannot know about a portal created five minutes ago. +# +# To hand-render one for a specific portal, copy this file, replace every +# {{PLACEHOLDER}}, and apply it the same way `brand-fuseseam.yaml` is applied +# (FuzeInfra/GitOps territory — never `kubectl`/`helm apply` from this repo). +# +# Placeholders: +# {{PORTAL_SLUG}} — the portal's slug (used only in the blueprint's own +# `metadata.name` for readability in the Authentik UI) +# {{PORTAL_DOMAIN}} — the domain Authentik matches the login Host header +# against (the portal's PRIMARY `portal_domains` row — +# see AC3: a portal with more than one domain needs one +# brand entry per additional domain it wants themed, +# each a copy of this template with its own `domain`) +# {{PORTAL_NAME}} — `portals.branding.name` (falls back to `portals.name`) +# {{ACCENT_HEX}} — `portals.branding.accent`, a `#rrggbb`/`#rgb` value; +# omit the whole `branding_custom_css` key entirely if +# the portal set no accent (an empty/missing accent is +# NOT the same as an empty string here) +# +# Schema: version 1, entries[] +# Model: authentik_brands.brand +# Idempotent: state: present + unique domain "{{PORTAL_DOMAIN}}" +version: 1 +metadata: + name: "Portal Brand — {{PORTAL_SLUG}}" +entries: + - model: authentik_brands.brand + state: present + identifiers: + domain: "{{PORTAL_DOMAIN}}" + attrs: + domain: "{{PORTAL_DOMAIN}}" + branding_title: "{{PORTAL_NAME}}" + # NEVER true — that flag is reserved for the platform brand + # (`brand-fuseseam.yaml`'s `fuzefront.com` entry). A per-portal brand is + # selected purely by its own `domain` match. + default: false + # Minimal accent-color override — a portal only overrides its accent, + # not the whole fuse-seam sheet (that stays the platform default's). + # Mirrors `authentik/portalBrand.ts`'s `customCss()` exactly; keep the + # two in sync if either changes. + branding_custom_css: | + :root { --fuse-accent: {{ACCENT_HEX}}; } + .pf-v5-c-login__main::before { background: {{ACCENT_HEX}} !important; } + .pf-v5-c-button.pf-m-primary, button[type="submit"] { background: {{ACCENT_HEX}} !important; } + .pf-v5-c-form-control:focus, input:focus { border-color: {{ACCENT_HEX}} !important; } + a, .pf-v5-c-button.pf-m-link { color: {{ACCENT_HEX}} !important; }