Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/api/FaableApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,19 @@ export class FaableApi<T = any> {
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<FaableApp>(next =>
data(
Expand Down
75 changes: 75 additions & 0 deletions src/api/auth_admin.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
65 changes: 60 additions & 5 deletions src/api/auth_admin.ts
Original file line number Diff line number Diff line change
@@ -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://<account>.auth.faable.link (or
Expand Down Expand Up @@ -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 (`<slug>.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<string | undefined> => {
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, 'issueAuthAccountToken'> = FaableApi.create({
authStrategy: bearer_strategy,
auth: { token: session_token }
})
): Promise<string | undefined> => {
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
Expand Down
Loading