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
61 changes: 44 additions & 17 deletions backend/applications/src/app-registry/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@ import {
navSectionRank,
} from './manifest.schema'

// Owner ruling 2026-08-19/2026-08-25: "there should be no app without orgid.
// at the minimum fuzefront itself the root org is the orgid for our own
// apps. like google owns docs, sheets, etc." `organization_id` on `apps` is
// NEVER null post-migration (see backend/applications/src/migrations —
// backfill + NOT NULL) — a first-party FuzeFront product (registered by a
// platform-admin service account, or a BUILTIN_MANIFESTS entry) is owned by
// the platform root org, not left org-less.
//
// This is the applications-service's own copy of the SAME fixed id
// backend/src/migrations/015_seed_root_platform_organization.ts seeds and
// exports as ROOT_ORG_ID — cross-service TS imports don't resolve (separate
// deployables), so every service that needs it re-declares the literal
// (same pattern as backend/applications/tests/portal-catalog.integration.test.ts
// and backend/security/src/migrations/014_seed_root_platform_organization.ts).
// Changing this value without a coordinated migration across every service
// orphans every row already keyed to it.
export const ROOT_ORG_ID = '00000000-0000-0000-0000-000000000010'

/**
* Derived side-menu ordering columns for a manifest. `manifest.nav` is the source
* of truth; these columns exist only so the list query can ORDER BY / keyset-paginate
Expand Down Expand Up @@ -76,13 +94,17 @@ function rowToApp(row: any): AppRecord {
* - visibility is public|marketplace (everyone), OR
* - visibility is organization AND the app's org is one the caller belongs to, OR
* - visibility is private AND the app's org is one the caller belongs to (owner org).
* An org-less (platform-global) app is treated as public for read.
* `organization_id` is NOT NULL on `apps` (see ROOT_ORG_ID's doc comment
* above) — the `!app.organizationId` branch below is unreachable defensive
* code, kept rather than deleted so a future regression on the DB
* constraint fails safe (deny) here instead of this predicate silently
* assuming a shape the schema no longer allows.
*/
export function canRead(app: AppRecord, caller: AppCaller): boolean {
if (caller.isPlatformAdmin) return true
const visibility = app.manifest.visibility ?? 'private'
if (visibility === 'public' || visibility === 'marketplace') return true
if (!app.organizationId) return true // platform-global → readable
if (!app.organizationId) return false // should be unreachable; fail closed, not open
const inOrg = caller.organizationIds.includes(app.organizationId)
if (visibility === 'organization' || visibility === 'private') return inOrg
return false
Expand Down Expand Up @@ -221,22 +243,24 @@ export class AppRegistryService {
const applyPortalGate = portalCtx?.mode === 'scoped'

query = query.where(builder => {
builder.where(orgLessOrPublic => {
// IMPORTANT: the OR-pair below MUST be its own explicit group
// (nested `.where(w => ...)`), not top-level calls on
// `orgLessOrPublic` directly — SQL's `AND` binds tighter than
// `OR`, so `visibility IN (...) OR organization_id IS NULL AND
// EXISTS(...)` parses as `visibility IN (...) OR (organization_id
// IS NULL AND EXISTS(...))`, which lets ANY `public`/`marketplace`
// app bypass the portal gate entirely regardless of EXISTS — the
// exact leak this filter exists to close. Caught by the S2
// no-leak integration test.
orgLessOrPublic.where(w => {
w.whereIn('visibility', ['public', 'marketplace']).orWhereNull('organization_id')
})
// `organization_id IS NULL` was a THIRD branch here (public/marketplace
// visibility OR org-less), removed by owner ruling 2026-08-25: "there
// should be no app without orgid ... fuzefront itself [is] the orgid
// for our own apps" (see ROOT_ORG_ID's doc comment above). It was a
// latent hole, not a feature — it made ANY row with no owning org
// visible to every caller regardless of `visibility`, and every
// first-party FuzeFront product ended up in exactly that state
// (registered with no organizationId). `organization_id` is NOT NULL
// on this table as of the migration backfilling those rows to
// ROOT_ORG_ID (backend/applications/src/migrations), so this branch
// is now simply "public/marketplace visibility" — nothing was ever
// org-less on purpose, so nothing is lost by requiring an org match
// for everything else.
builder.where(publicOrMarketplace => {
publicOrMarketplace.whereIn('visibility', ['public', 'marketplace'])
if (applyPortalGate) {
const portalId = portalCtx!.portalId as string
orgLessOrPublic.whereExists(function (this: any) {
publicOrMarketplace.whereExists(function (this: any) {
this.select(1)
.from('portal_apps')
.whereRaw('portal_apps.app_id = apps.id')
Expand Down Expand Up @@ -498,7 +522,10 @@ export class AppRegistryService {
status,
mode: manifest.mode,
builtin: true,
organization_id: null,
// ROOT_ORG_ID, not null (owner ruling — see ROOT_ORG_ID's own doc
// comment above): a builtin is a first-party FuzeFront product like
// any other, owned by the platform root org rather than org-less.
organization_id: ROOT_ORG_ID,
visibility: (manifest.visibility ?? 'public') as Visibility,
is_active: status === 'activated',
heartbeat_token: heartbeatToken,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { Knex } from 'knex'

// applications-service migration 011.
//
// Owner ruling 2026-08-25 (portal-registration-gap investigation): "I think
// there should be no app without orgid. at the minimum fuzefront itself the
// root org is the orgid for our own apps. like google owns docs, sheets,
// etc." An org-less `apps` row was never a deliberate feature — every
// first-party FuzeFront product ended up in that state simply because
// nothing ever set `organization_id` for it (registerBuiltin() hardcoded
// `null`; the app-registry's POST /apps route defaulted an omitted
// organizationId straight to `null` even for platform-admin callers). The
// visibility query then had to carry an `organization_id IS NULL` branch to
// keep those apps visible at all — a branch that, as a side effect, made
// ANY org-less row visible to EVERY caller regardless of its declared
// `visibility`. See backend/applications/src/app-registry/service.ts's
// `list()` for the query-side half of this fix, which REMOVES that branch;
// it must not run until this migration has guaranteed no row can be null,
// or apps that currently appear would silently disappear.
//
// This migration: backfill every existing org-less row to the platform root
// org, then make `organization_id` NOT NULL (with that root org as the
// column DEFAULT, so an insert path that forgets to set it explicitly lands
// on the root org instead of reintroducing the hole). The NOT NULL
// constraint is the part that makes this durable — a backfill alone is a
// point-in-time fix the next registration can silently undo.
//
// ROOT_ORG_ID is this service's own copy of the fixed id
// backend/src/migrations/015_seed_root_platform_organization.ts seeds and
// exports. Cross-service TS imports don't resolve (separate deployables),
// so every service that needs it re-declares the literal — same pattern as
// backend/applications/src/app-registry/service.ts's own copy (kept
// independent of that one deliberately: a migration must stay replayable on
// its own even if application code changes around it later) and
// backend/applications/tests/portal-catalog.integration.test.ts.
const ROOT_ORG_ID = '00000000-0000-0000-0000-000000000010'

export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn('apps', 'organization_id')
if (!hasColumn) {
// Never observed in practice — migration 002_update_apps_for_organizations
// always adds this column first — but fail loudly rather than silently
// no-op if a partially-migrated database somehow lacks it.
throw new Error(
'apps.organization_id does not exist — 002_update_apps_for_organizations must run first'
)
}

// The root org itself must exist before anything can reference it or be
// backfilled to it. It is seeded by a DIFFERENT service's migration tree
// (backend/src's 015_seed_root_platform_organization) against the SAME
// shared `organizations`/`apps` tables. If this service's migrations ever
// run in an environment where that one has not, backfilling to a
// still-absent id would violate apps_organization_id_foreign — fail loudly
// with a clear cause instead of a mysterious later FK violation.
const root = await knex('organizations').where({ id: ROOT_ORG_ID }).first()
if (!root) {
throw new Error(
`organizations.${ROOT_ORG_ID} (the platform root org) does not exist yet — ` +
'backend/src migration 015_seed_root_platform_organization must run before this one.'
)
}

const backfilled = await knex('apps')
.whereNull('organization_id')
.update({ organization_id: ROOT_ORG_ID })
if (backfilled > 0) {
// eslint-disable-next-line no-console
console.log(
`[011] backfilled ${backfilled} org-less app(s) to the platform root org ${ROOT_ORG_ID}`
)
}

// SET DEFAULT / SET NOT NULL on an already-conforming column is a no-op in
// Postgres, not an error — safe to run every boot, exactly like every
// other migration in this tree.
await knex.raw(`ALTER TABLE apps ALTER COLUMN organization_id SET DEFAULT '${ROOT_ORG_ID}'::uuid`)
await knex.raw('ALTER TABLE apps ALTER COLUMN organization_id SET NOT NULL')
}

export async function down(knex: Knex): Promise<void> {
await knex.raw('ALTER TABLE apps ALTER COLUMN organization_id DROP NOT NULL')
await knex.raw('ALTER TABLE apps ALTER COLUMN organization_id DROP DEFAULT')
// Deliberately does NOT un-backfill rows back to NULL — that would
// resurrect the exact hole this migration exists to close, and there is no
// way to tell which rows were genuinely org-less before vs. root-owned by
// this migration.
}
15 changes: 12 additions & 3 deletions backend/applications/src/routes/app-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import {
billingProfileSchema,
toValidationErrorBody,
} from '../app-registry/manifest.schema'
import { appRegistryService, canRead, canMutate } from '../app-registry/service'
import { appRegistryService, canRead, canMutate, ROOT_ORG_ID } from '../app-registry/service'
import { resolveCaller } from '../app-registry/caller'
import { checkAppRegistryPermission } from '../app-registry/permit'
import { getAppRegistryEmitter } from '../app-registry/events'
Expand Down Expand Up @@ -124,9 +124,18 @@ router.post('/apps', authenticateConsumerOrSession, async (req: any, res) => {
return res.status(400).json(toValidationErrorBody((parsed as any).error))
}
const { manifest, organizationId } = parsed.data
const orgId = organizationId ?? null

const caller = await resolveCaller(req.user)
// organization_id is NEVER null on `apps` (owner ruling 2026-08-25 — see
// ROOT_ORG_ID's doc comment in ../app-registry/service.ts). A platform
// admin (register.sh's service-account token, i.e. every first-party
// FuzeFront product self-registering) that omits organizationId is
// registering a first-party app and is attributed to the platform root
// org, never left org-less — this was the actual mechanism that put
// every builtin/self-registered product into the `organization_id IS
// NULL` state the visibility query used to special-case. A non-admin
// caller omitting it is still rejected below; they must name a real org
// they belong to.
const orgId = organizationId ?? (caller.isPlatformAdmin ? ROOT_ORG_ID : null)
// release flag (default OFF): the new write surface is dark until released.
if (!(await v1WriteGate(caller, orgId, res))) return

Expand Down
11 changes: 9 additions & 2 deletions backend/applications/tests/app-registry.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,15 @@ describe('app-registry BOLA visibility (canRead)', () => {
expect(canRead(appWith({ visibility: 'private', organizationId: 'org-z' }), platformAdmin)).toBe(true)
})

it('platform-global (org-less) apps are readable', () => {
expect(canRead(appWith({ visibility: 'private', organizationId: null }), memberOfOrgA)).toBe(true)
// Owner ruling 2026-08-25: "there should be no app without orgid ...
// fuzefront itself [is] the orgid for our own apps" — `organization_id`
// is NOT NULL on `apps` (migration 011_apps_organization_id_not_null.ts),
// so this state should be unreachable. It used to be treated as "readable
// by anyone" (a first-party app landed here because nothing ever set its
// org, not by design); canRead now fails CLOSED for it instead, so a
// future regression on the DB constraint denies rather than leaks.
it('an org-less app (should be unreachable — see the NOT NULL constraint) is denied, not universally readable', () => {
expect(canRead(appWith({ visibility: 'private', organizationId: null }), memberOfOrgA)).toBe(false)
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ async function seedApps(): Promise<void> {
status: 'activated',
mode: 'portal',
builtin: false,
organization_id: null,
organization_id: ROOT_ORG_ID, // NOT NULL (011_apps_organization_id_not_null.ts) — a public, org-less-in-spirit catalog fixture is now owned by the platform root org like any other first-party app.
visibility: 'public',
manifest: JSON.stringify({
manifestVersion: '1',
Expand Down Expand Up @@ -283,7 +283,7 @@ describe('FF-EPIC-12-S1 — PortalAppCatalogService (real Postgres)', () => {
status: 'activated',
mode: 'portal',
builtin: false,
organization_id: null,
organization_id: ROOT_ORG_ID, // NOT NULL (011_apps_organization_id_not_null.ts) — a public, org-less-in-spirit catalog fixture is now owned by the platform root org like any other first-party app.
visibility: 'public',
manifest: JSON.stringify({
manifestVersion: '1',
Expand Down Expand Up @@ -670,7 +670,7 @@ describe('FF-EPIC-12-S3 — portal catalog admin routes', () => {
status: 'activated',
mode: 'portal',
builtin: false,
organization_id: null,
organization_id: ROOT_ORG_ID, // NOT NULL (011_apps_organization_id_not_null.ts) — a public, org-less-in-spirit catalog fixture is now owned by the platform root org like any other first-party app.
visibility: 'public',
manifest: JSON.stringify({
manifestVersion: '1',
Expand Down
64 changes: 64 additions & 0 deletions backend/src/migrations/026_apps_organization_id_not_null.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { Knex } from 'knex'
import { ROOT_ORG_ID } from './015_seed_root_platform_organization'

// Owner ruling 2026-08-25 (portal-registration-gap investigation): "I think
// there should be no app without orgid. at the minimum fuzefront itself the
// root org is the orgid for our own apps. like google owns docs, sheets,
// etc." An org-less `apps` row was never a deliberate feature — see
// backend/applications/src/migrations/011_apps_organization_id_not_null.ts,
// this migration's sibling in the applications-service's OWN migration tree
// against the SAME shared `apps` table (this repo's schema for that table
// has historically been duplicated across both trees — see e.g. this
// service's 006_update_apps_for_organizations.ts and the applications
// service's 002_update_apps_for_organizations.ts, which apply the identical
// DDL). Kept in lock-step here for the same reason: whichever service's
// migrations happen to run first against a given database does the real
// work, and the other is a no-op — SET DEFAULT / SET NOT NULL on an
// already-conforming column raises nothing.
//
// `backend/src/routes/apps.ts`'s `scopeAppsQuery` is reconciled to match the
// production app-registry rule in the SAME change that adds this migration
// — see that file's own comment for the query-side half.

export async function up(knex: Knex): Promise<void> {
const hasColumn = await knex.schema.hasColumn('apps', 'organization_id')
if (!hasColumn) {
throw new Error(
'apps.organization_id does not exist — 006_update_apps_for_organizations must run first'
)
}

// This service is the one that SEEDS the root org (migration 015, in this
// same tree) — unlike the applications-service's copy of this migration,
// there is no cross-service ordering hazard to guard against here, but the
// assertion stays for the same reason: fail loudly with a clear cause
// instead of a bare FK-violation stack trace if that invariant is ever
// broken by a future change.
const root = await knex('organizations').where({ id: ROOT_ORG_ID }).first()
if (!root) {
throw new Error(
`organizations.${ROOT_ORG_ID} (the platform root org) does not exist yet — ` +
'015_seed_root_platform_organization must run before this one.'
)
}

const backfilled = await knex('apps')
.whereNull('organization_id')
.update({ organization_id: ROOT_ORG_ID })
if (backfilled > 0) {
// eslint-disable-next-line no-console
console.log(
`[026] backfilled ${backfilled} org-less app(s) to the platform root org ${ROOT_ORG_ID}`
)
}

await knex.raw(`ALTER TABLE apps ALTER COLUMN organization_id SET DEFAULT '${ROOT_ORG_ID}'::uuid`)
await knex.raw('ALTER TABLE apps ALTER COLUMN organization_id SET NOT NULL')
}

export async function down(knex: Knex): Promise<void> {
await knex.raw('ALTER TABLE apps ALTER COLUMN organization_id DROP NOT NULL')
await knex.raw('ALTER TABLE apps ALTER COLUMN organization_id DROP DEFAULT')
// Deliberately does NOT un-backfill rows back to NULL — see the sibling
// applications-service migration's down() for why.
}
Loading
Loading