let members sign in to the platform, which is the entire point
signin refused any non-owner arriving through the web or mobile platform origin: "This account can only sign in through its app." so a member could hold a gitea grant and still never reach a page — verified as a live 403 before this change. that rule was correct while single-user was the invariant. the only non-owner accounts were music-app accounts, and there was no way to say "this person may use the platform, but only these parts of it", so keeping them out entirely was the honest answer. capabilities say exactly that now, per feature, at both doors and on every request. so superAdminOnly is retired rather than patched. the web origin and the platform app get no path scoping — what their caller may reach is decided by their role, not by their Origin. per-app path scoping stays for the single-feature apps (music, vault, tail), where it still means something. note this WIDENS who may sign in: any Active account can now authenticate through the browser. that is the intended product change, and it is only safe because the capability backstop runs on every request behind it — which is why it lands after that, not before. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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/<slug>`. Only exceptions belong here.
|
||||
const APP_SCOPE_OVERRIDES: Record<string, string[]> = {
|
||||
@@ -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<string, OriginRule> = {};
|
||||
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user