diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index 837624a7..e2dbb547 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -35,9 +35,18 @@ const CHROME_EXTENSIONS: string[] = [ // the two fixed words: OFFICER_MUSIC_ORIGIN → MUSIC. const APP_ORIGIN_VAR = /^OFFICER_([A-Z0-9_]+)_ORIGIN$/; -// Apps that are the whole platform rather than one feature of it. The main Officer app keeps full access -// but is owner-only, so a non-owner can't drive the platform even with a token minted elsewhere. -const OWNER_ONLY_APPS = new Set(['APP']); +// Apps that are the whole platform rather than one feature of it, so they get NO path scoping — the main +// Officer app needs every prefix the web app needs. +// +// These used to be `superAdminOnly`, which refused a non-owner outright, here and at signin. That was +// correct while single-user was the invariant and the only non-owner accounts were music-app accounts: +// there was no way to express "this person may use the platform, but only these parts of it", so the +// honest answer was to keep them out of it entirely. +// +// Capabilities express exactly that, per feature, at both doors. So the blunt version is gone — a member +// signs into the web app and sees what their role was granted. Keeping both would mean a member who has +// been granted Gitea still cannot reach the page, which is not a second layer of defence, just a bug. +const PLATFORM_APPS = new Set(['APP']); // Where an app's API surface isn't `/api/`. Only exceptions belong here. const APP_SCOPE_OVERRIDES: Record = { @@ -70,28 +79,24 @@ function pathAllowed(path: string, prefixes: string[]): boolean { } // Per-origin access rules, enforced globally by originScopeMiddleware (mounted in hono.ts): -// - superAdminOnly: authenticated requests carrying this Origin must be the platform owner -// (see super-admin.ts). Unauthenticated requests (e.g. signin) pass through — userMiddleware -// still guards protected routes. Stops a non-owner from using the web / mobile platform, even by -// replaying a token minted through the music app. // - paths: this Origin may reach ONLY these path prefixes; anything else is 403. // Origins with no rule keep full access. Rules no-op for env values that are unset. // // App rules are derived, not written: an app may reach /api/auth (it has to sign in) plus the one -// feature it is named for — OFFICER_VAULT_ORIGIN gets /api/auth + /api/vault. No superAdminOnly on -// those: the owner signs in through them, and e.g. vault calls carry Bitwarden tokens rather than -// platform accounts, so the account backstop never applies (verify() → null → passes). -type OriginRule = { superAdminOnly?: boolean; paths?: string[] }; +// feature it is named for — OFFICER_VAULT_ORIGIN gets /api/auth + /api/vault. +type OriginRule = { paths?: string[] }; const ORIGIN_RULES: Record = {}; for (const { slug, origin } of APP_ORIGIN_LIST) { - ORIGIN_RULES[origin] = OWNER_ONLY_APPS.has(slug) - ? { superAdminOnly: true } + ORIGIN_RULES[origin] = PLATFORM_APPS.has(slug) + ? {} // no path scoping; the capability backstop is what limits a platform app's caller : { paths: ['/api/auth', ...(APP_SCOPE_OVERRIDES[slug] ?? [`/api/${slug.toLowerCase()}`])] }; } -// Last, so the web origin wins if an app ever declares the same one. -if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = { superAdminOnly: true }; +// Last, so the web origin wins if an app ever declares the same one. No path scoping, for the same reason +// as PLATFORM_APPS above: the web app is the whole platform, and what its caller may reach is decided by +// their capabilities rather than by their Origin. +if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = {}; // ── TEMPORARY: origin checking switched off ── // ALLOW_ANY_ORIGIN=true accepts every Origin, everywhere, and skips the per-origin path scoping. @@ -120,11 +125,6 @@ export function isOriginCheckDisabled(path?: string): boolean { /** @deprecated use isOriginCheckDisabled — kept so existing call sites read the same. */ export const isMusicOriginExempt = isOriginCheckDisabled; -// True when an Origin is reserved for the platform owner (used at signin to reject a non-owner login). -export function isSuperAdminOnlyOrigin(origin: string | undefined): boolean { - return !!origin && ORIGIN_RULES[origin]?.superAdminOnly === true; -} - export function isOriginAllowed(origin: string | undefined, host?: string): boolean { if (IS_DEV_BUILD) return true; if (ALLOW_ANY_ORIGIN) return true; @@ -218,9 +218,6 @@ export const originScopeMiddleware: MiddlewareHandler = async function (ctx, nex if (rule.paths && !pathAllowed(path, rule.paths)) { throw errors.FORBIDDEN('Origin not permitted for this resource'); } - if (rule.superAdminOnly && payload && !isOwner) { - throw errors.FORBIDDEN('This origin is restricted to the platform owner'); - } } return next(); diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts index 81ba6be4..eb3ecdec 100755 --- a/src/servers/api/auth/signin.ts +++ b/src/servers/api/auth/signin.ts @@ -3,8 +3,6 @@ import { getUserByEmail, getPasskeysByUserIdAndOrigin } from 'officerdb'; import { sign } from '@@/jwt'; import argon2 from 'argon2'; import * as errors from '@@/custom-errors'; -import { isSuperAdminOnlyOrigin } from '@@/_middlewares'; -import { isSuperAdmin } from '@@/super-admin'; import { isLockdown, noteBlocked } from './panic'; const TEST_USERS: number[] = []; @@ -36,11 +34,10 @@ export const signinHandler: Handler = async function (ctx) { const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password)); if (!isValidPassword) throw errors.UNAUTHORIZED(); - // A non-owner account may authenticate only through an app origin (e.g. the music app), never the - // Super-Admin-only web/mobile origins — so its credentials can't open the full platform in a browser. - if (isSuperAdminOnlyOrigin(origin) && !(await isSuperAdmin({ id: dbUser.id }))) { - throw errors.FORBIDDEN('This account can only sign in through its app.'); - } + // There used to be a check here refusing any non-owner signing in through the web or mobile platform + // origin. It is gone deliberately: onboarding members who use the platform in a browser is the point, + // and what they can reach once inside is decided by their role's capabilities at every request. A rule + // that let a member hold a Gitea grant they could never sign in to use was not defence, just a wall. const { id, name, username } = dbUser;