From 51b4fff6958c5aa8dc60a479038b8a410a9c0710 Mon Sep 17 00:00:00 2001 From: Marc Pomar Date: Thu, 24 Sep 2026 19:33:54 +0200 Subject: [PATCH] =?UTF-8?q?fix(auth):=20`faable=20auth`=20gestiona=20un=20?= =?UTF-8?q?tenant=20con=20su=20token=20de=20gesti=C3=B3n,=20no=20con=20el?= =?UTF-8?q?=20de=20faable=20login?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `faable auth clients|actions|logs` mandaba el token de `faable login` (usuario de la cuenta raíz) contra el tenant: solo funcionaba porque auth trataba ese token como superadmin de plataforma, y la fase 3 del aislamiento multi-tenant lo quita. Ahora resuelve el tenant (--account, o el host de --auth-url por la ruta pública /account/host/:host), pide a la api POST /auth-accounts/:id/token (que comprueba la pertenencia) y usa ese token. Si la api no emite uno (tenant de plataforma, 404/501/503 o sin red), sigue con el de sesión como antes; un 403 (no eres miembro) es un error. --- src/api/FaableApi.ts | 13 +++++++ src/api/auth_admin.test.ts | 75 ++++++++++++++++++++++++++++++++++++++ src/api/auth_admin.ts | 65 ++++++++++++++++++++++++++++++--- 3 files changed, 148 insertions(+), 5 deletions(-) create mode 100644 src/api/auth_admin.test.ts diff --git a/src/api/FaableApi.ts b/src/api/FaableApi.ts index 66d4108..77fb401 100644 --- a/src/api/FaableApi.ts +++ b/src/api/FaableApi.ts @@ -328,6 +328,19 @@ export class FaableApi { return new FaableApi(config) } + // A short-lived Management API token for ONE Faable Auth tenant, issued by + // the deploy api after checking the caller belongs to the project that owns + // it (phase 2 of arch/auth/management-api-tenant-isolation.md). `faable auth` + // manages tenants with it instead of the `faable login` token, which stops + // being platform-superadmin in phase 3. + async issueAuthAccountToken(account_id: string) { + return data( + this.client.post<{ access_token: string; expires_in: number }>( + `/auth-accounts/${account_id}/token` + ) + ) + } + async list() { return allPages(next => data( diff --git a/src/api/auth_admin.test.ts b/src/api/auth_admin.test.ts new file mode 100644 index 0000000..5477413 --- /dev/null +++ b/src/api/auth_admin.test.ts @@ -0,0 +1,75 @@ +// `faable auth` manages a tenant with the per-tenant token the deploy api +// issues (phase 2/3 of arch/auth/management-api-tenant-isolation.md), and only +// falls back to the `faable login` token where no such token exists. +import test from 'ava' +import { issueTenantToken, resolveAccountByHost } from './auth_admin' + +const failing = (status?: number) => ({ + issueAuthAccountToken: async () => { + throw status ? { response: { status } } : new Error('ECONNREFUSED') + } +}) + +test('uses the tenant token the api issues', async t => { + const api = { + issueAuthAccountToken: async (id: string) => ({ + access_token: `tenant-token-for-${id}`, + expires_in: 900 + }) + } + t.is( + await issueTenantToken('session', 'account_a', api), + 'tenant-token-for-account_a' + ) +}) + +test('falls back to the session token when no tenant token exists', async t => { + for (const status of [404, 501, 503, undefined]) { + t.is( + await issueTenantToken('session', 'account_a', failing(status)), + undefined, + String(status) + ) + } +}) + +test('a 403 (not a member) is an error, never a fallback', async t => { + const err = await t.throwsAsync( + issueTenantToken('session', 'account_a', failing(403)) + ) + t.regex(err!.message, /not a member/) +}) + +test('other api errors propagate', async t => { + await t.throwsAsync(issueTenantToken('session', 'account_a', failing(500)), { + any: true + }) +}) + +test('resolves the tenant from the Auth host through the public lookup', async t => { + const seen: string[] = [] + const auth = { + fetcher: { + get: async (url: string) => { + seen.push(url) + return { id: 'account_from_host' } + } + } + } as any + t.is( + await resolveAccountByHost('https://acme.auth.faable.link', auth), + 'account_from_host' + ) + t.deepEqual(seen, ['/account/host/acme.auth.faable.link']) +}) + +test('an unresolvable host leaves the tenant to the server', async t => { + const auth = { + fetcher: { + get: async () => { + throw { response: { status: 404 } } + } + } + } as any + t.is(await resolveAccountByHost('https://nope.example', auth), undefined) +}) diff --git a/src/api/auth_admin.ts b/src/api/auth_admin.ts index afa7a25..0d92da0 100644 --- a/src/api/auth_admin.ts +++ b/src/api/auth_admin.ts @@ -1,8 +1,10 @@ import type { FaableAuthApi } from '@faable/auth-sdk' import { CredentialsStore } from '../lib/CredentialsStore' import { log } from '../log' -import { createBearerAuthApi } from './auth' +import { FaableApi } from './FaableApi' +import { createAnonymousAuthApi, createBearerAuthApi } from './auth' import { loadLiveCredentials } from './session' +import { bearer_strategy } from './strategies/bearer.strategy' // Default tenant host. `faable auth` is customer-facing: a customer targets // their own tenant with --auth-url https://.auth.faable.link (or @@ -44,10 +46,63 @@ export const requireAuthAdmin = async ( const domain = opts.authUrl || process.env.FAABLE_AUTH_URL || DEFAULT_AUTH_URL const account = opts.account || process.env.FAABLE_AUTH_ACCOUNT - // The session's bearer as a strategy — with the CLI's own identity on the - // wire instead of `auth-sdk` — scoped to the target tenant when one was - // named. The server decides whether this token may manage that tenant. - return createBearerAuthApi(token, { domain, account }) + const tenant = account ?? (await resolveAccountByHost(domain)) + const tenantToken = tenant ? await issueTenantToken(token, tenant) : undefined + + // Prefer the per-tenant management token; the session's bearer is the + // fallback (the platform tenant, or an api that can't issue one), exactly + // what `faable auth` sent before phase 2. Either way, scoped to the tenant. + return createBearerAuthApi(tenantToken ?? token, { + domain, + account: tenant ?? account + }) +} + +// The tenant behind an Auth host (`.auth.faable.link` or a custom +// domain), through the anonymous public lookup. `undefined` when it can't be +// resolved: the server then picks the tenant from the host, as before. +export const resolveAccountByHost = async ( + domain: string, + auth = createAnonymousAuthApi({ domain }) +): Promise => { + try { + const host = new URL(domain).host + const account = await auth.fetcher.get<{ + id?: string + }>(`/account/host/${host}`) + return account?.id + } catch { + return undefined + } +} + +// Statuses that mean "no per-tenant token for this one right now" — the +// platform tenant (404 tenant_token_not_issued), an api without the endpoint, +// or auth unable to mint. Anything else (403: not a member) is a real refusal. +const FALLBACK_STATUSES = new Set([404, 501, 503]) + +export const issueTenantToken = async ( + session_token: string, + account_id: string, + api: Pick = FaableApi.create({ + authStrategy: bearer_strategy, + auth: { token: session_token } + }) +): Promise => { + try { + const { access_token } = await api.issueAuthAccountToken(account_id) + return access_token + } catch (e) { + const status = (e as { response?: { status?: number } })?.response?.status + if (status === 403) { + throw new Error( + `Forbidden (403): you are not a member of the project that owns ${account_id}, so you can't manage it.`, + { cause: e } + ) + } + if (status === undefined || FALLBACK_STATUSES.has(status)) return undefined + throw e + } } // Translate raw management-API failures into actionable CLI errors. Everything