Files
zooqueen 81a4dcfaaa Write tenant membership from claims only
The `tenants` array on the users collection carries field access that refuses
create and update, so REST and GraphQL cannot add a row to it. The strategy
rewrites it from the IAM `owner` claim on every sign-in through the local API,
which overrides access.

Membership is what scopes every query, so a caller able to append to the array
reads and writes the tenant it names.

Tests: the acceptance proof gains an org admin appending a foreign tenant to
their own row, asserting the row is unchanged and the tenant stays unreachable,
alongside controls that a local-API write of both `iamOrg` and `tenants` lands.
2026-08-14 16:14:47 -07:00

308 lines
9.6 KiB
TypeScript

/**
* Tenant boundary acceptance proof, against this app's real config and a real
* database. Two tenants, three principals, every call through the access layer
* (`overrideAccess: false`), so a refusal is the same 403 a REST caller gets.
*
* Run: HANZO_ORG=tenancy-proof tsx scripts/tenancy.ts
*/
import { getCMS } from '@hanzo/cms'
const step = (m: string) => {
console.log(`\n=== ${m} ===`)
}
const ok = (m: string) => {
console.log(` ✓ ${m}`)
}
const fail = (m: string) => {
console.error(` ✗ ${m}`)
process.exitCode = 1
}
/** Record one assertion: `passed` prints the claim, otherwise `detail` prints why. */
const check = (passed: boolean, claim: string, detail: string) => {
if (passed) {
ok(claim)
} else {
fail(detail)
}
}
/** Run a call that must be refused, and assert it is refused with a 403. */
const expectForbidden = async (label: string, call: () => Promise<unknown>) => {
try {
await call()
} catch (error) {
const status = (error as { status?: number }).status
check(
status === 403,
`${label} -> 403`,
`${label} threw ${status ?? (error as Error).message}, expected 403`,
)
return
}
fail(`${label} was ALLOWED`)
}
const run = async () => {
process.env.HANZO_ORG = process.env.HANZO_ORG || 'tenancy-proof'
const config = (await import('../src/payload.config.js')).default
const cms = await getCMS({ config })
const tenantFor = async (slug: string) => {
const found = await cms.find({
collection: 'tenants',
limit: 1,
where: { slug: { equals: slug } },
})
return (
found.docs[0] ?? (await cms.create({ collection: 'tenants', data: { name: slug, slug } }))
)
}
const acme = await tenantFor('acme')
const maxpower = await tenantFor('maxpower')
const principal = async (email: string, iamOrg: string, isAdmin: boolean, tenant?: unknown) => {
const found = await cms.find({
collection: 'users',
limit: 1,
where: { email: { equals: email } },
})
const data = {
email,
iamOrg,
iamSub: email,
isAdmin,
...(tenant ? { tenants: [{ tenant }] } : {}),
}
const doc = found.docs[0]
? await cms.update({ id: found.docs[0].id, collection: 'users', data })
: await cms.create({
collection: 'users',
data: { ...data, password: 'proof-only-never-shipped' },
})
return { ...doc, collection: 'users' }
}
// Both org admins carry `isAdmin`, so a predicate reading it as a platform
// privilege shows up in every cross-tenant case below.
const acmeAdmin = await principal('[email protected]', 'acme', true, acme.id)
const maxAdmin = await principal('[email protected]', 'maxpower', true, maxpower.id)
const superAdmin = await principal('[email protected]', 'admin', false, acme.id)
step("Seed: one page per tenant, written by that tenant's own admin")
const acmePage = await cms.create({
collection: 'pages',
data: { slug: `acme-${Date.now()}`, tenant: acme.id, title: 'Acme Private' },
overrideAccess: false,
user: acmeAdmin,
})
ok(`acme admin created a page in their OWN tenant (id=${acmePage.id})`)
const maxPage = await cms.create({
collection: 'pages',
data: { slug: `max-${Date.now()}`, tenant: maxpower.id, title: 'MaxPower Private' },
overrideAccess: false,
user: maxAdmin,
})
ok(`maxpower admin created a page in their OWN tenant (id=${maxPage.id})`)
step('Control: own-tenant management is UNAFFECTED')
const updated = await cms.update({
id: acmePage.id,
collection: 'pages',
data: { title: 'Acme Private (edited)' },
overrideAccess: false,
user: acmeAdmin,
})
check(
updated.title === 'Acme Private (edited)',
'acme admin updated their own page',
'acme admin could not update their own page',
)
const ownRead = await cms.find({ collection: 'pages', overrideAccess: false, user: acmeAdmin })
const ownSlugs = ownRead.docs.map((d) => (d as { slug?: string }).slug)
check(
ownSlugs.includes(acmePage.slug as string),
`acme admin reads their own page (${ownRead.totalDocs} doc(s) visible)`,
'acme admin cannot read their own page',
)
step('Cross-tenant is DENIED for an org admin')
check(
!ownSlugs.includes(maxPage.slug as string),
"maxpower's page is absent from the acme admin's list",
`ISOLATION BREACH: acme admin listed [${ownSlugs.join(', ')}]`,
)
const byId = await cms.findByID({
id: maxPage.id,
collection: 'pages',
disableErrors: true,
overrideAccess: false,
user: acmeAdmin,
})
check(
!byId,
"by-id read of maxpower's page is blocked",
'ISOLATION BREACH: by-id read returned it',
)
await expectForbidden("acme admin WRITING into maxpower's tenant", () =>
cms.create({
collection: 'pages',
data: { slug: `steal-${Date.now()}`, tenant: maxpower.id, title: 'Planted' },
overrideAccess: false,
user: acmeAdmin,
}),
)
await expectForbidden("acme admin MOVING their page into maxpower's tenant", () =>
cms.update({
id: acmePage.id,
collection: 'pages',
data: { tenant: maxpower.id },
overrideAccess: false,
user: acmeAdmin,
}),
)
await expectForbidden('acme admin planting a DRAFT, where validate is skipped', () =>
cms.create({
collection: 'pages',
data: { slug: `draft-${Date.now()}`, _status: 'draft', tenant: maxpower.id, title: 'Draft' },
draft: true,
overrideAccess: false,
user: acmeAdmin,
}),
)
await expectForbidden('acme admin reading the job queue', () =>
cms.find({ collection: 'cms-jobs', overrideAccess: false, user: acmeAdmin }),
)
step('`iamOrg` is written from claims only')
// The field decides who crosses a tenant boundary, so a client able to write
// it is a client able to promote itself. Field access drops the value rather
// than refusing the request, so assert the stored value, not the status.
const selfRaised = await cms.update({
id: acmeAdmin.id,
collection: 'users',
data: { iamOrg: 'admin', isAdmin: true },
overrideAccess: false,
user: acmeAdmin,
})
check(
(selfRaised as { iamOrg?: string }).iamOrg === 'acme',
'an org admin writing iamOrg on their own row leaves it unchanged',
`SELF-PROMOTION: iamOrg became ${(selfRaised as { iamOrg?: string }).iamOrg}`,
)
const stillScoped = await cms.find({
collection: 'pages',
overrideAccess: false,
user: { ...selfRaised, collection: 'users' },
})
check(
!stillScoped.docs.some((d) => (d as { slug?: string }).slug === maxPage.slug),
'and they still cannot reach the other tenant',
'SELF-PROMOTION reached another tenant',
)
step('Tenant membership is written from claims only')
const selfJoined = await cms.update({
id: acmeAdmin.id,
collection: 'users',
data: { tenants: [{ tenant: acme.id }, { tenant: maxpower.id }] },
overrideAccess: false,
user: acmeAdmin,
})
const joinedRows = (selfJoined as { tenants?: unknown[] }).tenants ?? []
check(
joinedRows.length === 1,
'an org admin adding a foreign tenant to their own row leaves it unchanged',
`SELF-JOIN: the row now holds ${joinedRows.length} tenants`,
)
const afterJoin = await cms.find({
collection: 'pages',
overrideAccess: false,
user: { ...selfJoined, collection: 'users' },
})
check(
!afterJoin.docs.some((d) => (d as { slug?: string }).slug === maxPage.slug),
'and the other tenant stays unreachable',
'SELF-JOIN reached another tenant',
)
step('Controls: the strategy writes both on sign-in')
// The strategy writes through the local API, which overrides access. If these
// fail, every sign-in silently stops assigning identity and tenancy.
const byStrategy = await cms.update({
id: acmeAdmin.id,
collection: 'users',
data: { iamOrg: 'acme-renamed', tenants: [{ tenant: acme.id }, { tenant: maxpower.id }] },
})
check(
(byStrategy as { iamOrg?: string }).iamOrg === 'acme-renamed',
'the strategy still writes iamOrg',
'the field guard also blocked the strategy on iamOrg',
)
check(
((byStrategy as { tenants?: unknown[] }).tenants ?? []).length === 2,
'the strategy still writes tenant membership',
'the field guard also blocked the strategy on tenants',
)
await cms.update({
id: acmeAdmin.id,
collection: 'users',
data: { iamOrg: 'acme', tenants: [{ tenant: acme.id }] },
})
step('The reserved `admin` org still crosses every tenant')
const superRead = await cms.find({ collection: 'pages', overrideAccess: false, user: superAdmin })
const superSlugs = superRead.docs.map((d) => (d as { slug?: string }).slug)
check(
superSlugs.includes(acmePage.slug as string) && superSlugs.includes(maxPage.slug as string),
`super reads BOTH tenants (${superRead.totalDocs} doc(s))`,
`super saw only [${superSlugs.join(', ')}]`,
)
const superWrite = await cms.create({
collection: 'pages',
data: { slug: `super-${Date.now()}`, tenant: maxpower.id, title: 'Super Write' },
overrideAccess: false,
user: superAdmin,
})
check(
Boolean(superWrite.id),
'super writes into a tenant it does not belong to',
'super write refused',
)
const superJobs = await cms.find({
collection: 'cms-jobs',
overrideAccess: false,
user: superAdmin,
})
check(
typeof superJobs.totalDocs === 'number',
'super reads the job queue',
'super cannot read the job queue',
)
console.log(
process.exitCode ? '\n=== TENANCY PROOF FAILED ===' : '\n=== TENANCY PROOF COMPLETE ===',
)
process.exit(process.exitCode || 0)
}
run().catch((e) => {
console.error('TENANCY PROOF FAILED:', e)
process.exit(1)
})