Skip to content
24 changes: 24 additions & 0 deletions .changeset/23190-auth-gate-cli-sites.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
'@objectstack/runtime': minor
'@objectstack/rest': minor
'@objectstack/cloud-connection': minor
---

fix(runtime,rest,cloud-connection)!: the declared-endpoint fallback, REST's direct-mounted publish and federation routes, and cloud-connection's session doors refuse a session the ADR-0069 authentication policy holds

Clause-②: no (narrowing) — a session ADR-0069 holds out of protected resources is refused by doors that answered it; core's existing exports only, no published schema or export moves.

<!-- adr-0087: not-required (no-migration-prescription) A runtime authentication narrowing at three groups of doors, not a metadata change. No spec key, export, option, config field, response field or stored shape is removed, renamed or re-shaped, and a session the policy does not hold receives the same answers as before, so there is no tombstone and nothing for `objectstack migrate meta` to rewrite. What narrows is which sessions these doors serve: a session whose user carries the ADR-0069 posture (an expired password, or enforced MFA not yet enrolled) is now refused with the gate's 403 before the door's own logic runs. The other categories are closed on facts: the three packages publish (not unpublished); no ADR-0087 id covers these doors and this diff adds none (not registered / already-registered); and no published interface or type changes, the new fields living on package-internal types only (not runtime-interface-only / type-surface-only). -->

**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.

ADR-0069 holds a signed-in user out of every protected resource while their password has expired or while enforced MFA is not yet enrolled, until they remediate. REST's `enforceAuth` and the runtime dispatcher already refuse such a session. Three groups of doors that resolve a session on their own never asked, so they served it as an ordinary signed-in caller:

- **`@objectstack/runtime`**: the declared-endpoint fallback (app-declared `apis:` endpoints, served by the dispatcher plugin's fallback seam). Its policy chain now asks the shared decision (`evaluateAuthGate`, `@objectstack/core`) before `authRequired` and before the endpoint's target runs, on every declared endpoint, an `authRequired: false` one included, because the target runs as the caller. While a gate feature is active the chain reads the session for this, and when an active gate cannot read the session the request is answered `503` (`SERVICE_UNAVAILABLE`), as REST answers it. No declared path is handed to the allow-list, so no endpoint can exempt itself through the prefix it sits under.
- **`@objectstack/rest`**: the direct-mounted package publish door (`registerPackageRoutes`) and the external-datasource federation family (`registerExternalDatasourceRoutes`). Each refuses on the `authGate` the REST resolver already puts on the execution context it hands them.
- **`@objectstack/cloud-connection`**: `CloudConnectionPlugin`'s seven session doors (bind, poll, unbind, install, and the three installed-state reads) and all five `MarketplaceInstallLocalPlugin` doors (install, list, uninstall, reseed, purge). Each plugin asks the gate once, where it resolves the session, and every door that resolves one refuses on it.

- **Refused now:** a held session gets `403` with the gate's own `code` (`PASSWORD_EXPIRED` or `MFA_REQUIRED`) and `message`, in each door's declared error envelope, before the door does anything else. This is the answer REST gives the same session.
- **Unchanged:** a session the policy does not hold is served exactly as before, an API-key caller is unaffected because it has no session to hold, and an anonymous caller gets the same `401`. `CloudConnectionPlugin`'s status read and `MarketplaceProxyPlugin`'s catalog proxy resolve no session and answer every caller alike, so they are unchanged.

What changes for you. A user the policy holds must remediate (change the password, or enroll an authenticator) before these doors answer them, as on every other protected route. Nothing needs configuring: the posture comes from the kernel's `auth` service, as it does for REST and the dispatcher.
384 changes: 384 additions & 0 deletions packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts

Large diffs are not rendered by default.

54 changes: 48 additions & 6 deletions packages/cloud-connection/src/cloud-connection-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
* `apps/objectos` (multi-tenant) and `apps/objectos-ee` (single-environment)
* — wire the same canonical implementation. cloud ADR-0008 Phase 2 moves this
* surface into the open `@objectstack/cloud-connection` package; keep this
* file dependency-light (structural Plugin types, no @objectstack/core) so
* the move is mechanical.
* file dependency-light (structural Plugin types) so the move is mechanical.
* The one value it takes from `@objectstack/core` is the ADR-0069 gate's
* decision, `evaluateAuthGate`: a second copy of a security decision is the
* defect that function exists to prevent (see `sessionFromAuthService`).
*
* ## Environment & session resolution — two modes
*
Expand All @@ -50,7 +52,7 @@
* self-hosted runtimes).
*/

// Minimal structural plugin contract — keep this module independent of
// Minimal structural plugin contract — keep this module's TYPES independent of
// @objectstack/core so the cloud ADR-0008 Phase 2 move to the open package stays
// mechanical (mirrors the other plugins in this package).
interface PluginContext {
Expand Down Expand Up @@ -78,12 +80,22 @@ interface Plugin {
}

import { hostname } from 'node:os';
import { evaluateAuthGate, type AuthGate } from '@objectstack/core';
import { inProcessSessionReadInput } from '@objectstack/types';
import { ConnectionCredentialStore } from './connection-credential-store.js';
import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js';

const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection';

/**
* A signed-in caller of this surface, and the ADR-0069 posture its session
* carries (`authGate`, present only for a session the policy holds).
*/
interface ResolvedSession {
userId: string;
authGate?: AuthGate;
}

/**
* The one message every `ENVIRONMENT_NOT_FOUND` refusal on this surface carries.
*
Expand Down Expand Up @@ -201,17 +213,40 @@ export class CloudConnectionPlugin implements Plugin {
manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE);
} catch { /* no manifest service — headless kernel */ }

const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<{ userId?: string } | null> => {
const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<ResolvedSession | null> => {
const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc;
// [#22258] The in-process session-read rule: a request carrying a
// session cookie reads without renewal, because this route's
// response never carries a renewed cookie.
const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers));
const userId = session?.user?.id ? String(session.user.id) : undefined;
return userId ? { userId } : null;
if (!userId) return null;
// ADR-0069: the ONE consult of the authentication-policy gate for
// every door on this surface, asked of the session this read
// returned (the posture rides its user, computed by plugin-auth's
// `customSession` enrichment only while a gate feature is active).
// Both modes read here: the host kernel's `auth` (single-env) and
// the environment's own kernel's (multi-tenant).
//
// No request path is passed, so the allow-list can grant no
// exemption (an empty path earns none, #7898). Nothing this plugin
// mounts is a remediation or bootstrap route.
const authGate = evaluateAuthGate(session?.user, '');
return authGate ? { userId, authGate } : { userId };
};

const resolveSession = async (environmentId: string, rawReq: Request): Promise<{ userId?: string } | null> => {
/**
* ADR-0069: a session the authentication policy HOLDS (an expired
* password, enforced MFA not yet enrolled) is gated out of every
* protected resource until it remediates. Every door that resolves
* a session answers it this way, before its own 401 and before any
* control-plane call: REST's answer (`403` with the gate's own
* `code` and `message`), in this surface's envelope.
*/
const refuseHeldSession = (c: any, gate: AuthGate) =>
c.json({ success: false, error: { code: gate.code, message: gate.message } }, 403);

const resolveSession = async (environmentId: string, rawReq: Request): Promise<ResolvedSession | null> => {
try {
if (this.cfg.singleEnvironment) {
// Single-env: the host kernel owns auth.
Expand Down Expand Up @@ -294,6 +329,7 @@ export class CloudConnectionPlugin implements Plugin {
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
}
const session = await resolveSession(environmentId ?? '', c.req.raw);
if (session?.authGate) return refuseHeldSession(c, session.authGate);
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to connect a cloud account.' } }, 401);
if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503);
try {
Expand Down Expand Up @@ -349,6 +385,7 @@ export class CloudConnectionPlugin implements Plugin {
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
}
const session = await resolveSession(environmentId ?? '', c.req.raw);
if (session?.authGate) return refuseHeldSession(c, session.authGate);
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503);

Expand Down Expand Up @@ -447,6 +484,7 @@ export class CloudConnectionPlugin implements Plugin {
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
}
const session = await resolveSession(environmentId ?? '', c.req.raw);
if (session?.authGate) return refuseHeldSession(c, session.authGate);
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);

// Revoke cloud-side FIRST (the oscc_ bearer self-identifies;
Expand Down Expand Up @@ -495,6 +533,7 @@ export class CloudConnectionPlugin implements Plugin {
// explicit env-admin role check; today only owners/admins obtain
// a session via SSO-as-owner.)
const session = await resolveSession(environmentId, c.req.raw);
if (session?.authGate) return refuseHeldSession(c, session.authGate);
if (!session?.userId) {
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to install apps.' } }, 401);
}
Expand Down Expand Up @@ -545,6 +584,7 @@ export class CloudConnectionPlugin implements Plugin {
}

const session = await resolveSession(environmentId, c.req.raw);
if (session?.authGate) return refuseHeldSession(c, session.authGate);
if (!session?.userId) {
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
}
Expand Down Expand Up @@ -607,6 +647,7 @@ export class CloudConnectionPlugin implements Plugin {
}

const session = await resolveSession(environmentId, c.req.raw);
if (session?.authGate) return refuseHeldSession(c, session.authGate);
if (!session?.userId) {
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
}
Expand Down Expand Up @@ -644,6 +685,7 @@ export class CloudConnectionPlugin implements Plugin {
}

const session = await resolveSession(environmentId ?? '', c.req.raw);
if (session?.authGate) return refuseHeldSession(c, session.authGate);
if (!session?.userId) {
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
}
Expand Down
72 changes: 63 additions & 9 deletions packages/cloud-connection/src/marketplace-install-local-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ import type { Plugin, PluginContext } from '@objectstack/core';
import {
resolveAuthzContext,
isAuthzStoreUnavailableError,
// ADR-0069 — the authentication-policy gate's ONE decision, asked of the
// session `resolveInstallPrincipal` reads (see `refuseHeldSession`).
evaluateAuthGate,
type AuthGate,
// [#16013] The ADMISSION seam's posture derivation, in ONE call — the same
// symbol `packages/rest`'s seam now imports, for the same reason. It reads
// the posture IN FORCE off the kernel's `tenancy` service and carries
Expand Down Expand Up @@ -529,6 +533,42 @@ function organizationWallActive(ctx: PluginContext): boolean {
const NO_ACTIVE_ORGANIZATION_CODE = 'PERMISSION_DENIED';
const NO_ACTIVE_ORGANIZATION_STATUS = 403;

/**
* The caller one install-local admission resolved: identity and grants off the
* shared resolver, plus the two postures the doors read.
*/
interface InstallPrincipal {
userId: string;
systemPermissions: string[];
/** [#15204 W2] The ADR-0095 posture rung the resolver derived. */
posture?: unknown;
/** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */
tenancyPosture?: TenancyPosture;
/**
* ADR-0069: the authentication-policy posture of the session this
* admission resolved, present only for a session the policy holds.
* `refuseHeldSession` refuses on it.
*/
authGate?: AuthGate;
}

/**
* ADR-0069: the refusal every install-local door gives a session the
* authentication policy HOLDS (an expired password, enforced MFA not yet
* enrolled), right after the 401 and before the caller's capabilities are
* consulted or the ledger is read. Such a session is gated out of every
* protected resource until it remediates, so it gets REST's answer: `403`
* with the gate's own `code` and `message`, in this plugin's envelope. The
* consult itself happens once, in `resolveInstallPrincipal`; this only
* answers on it, in one place, so a door added later cannot copy a weaker
* answer. `null` when the session is not held.
*/
function refuseHeldSession(c: any, principal: InstallPrincipal): Response | null {
if (!principal.authGate) return null;
const { code, message } = principal.authGate;
return c.json({ success: false, error: { code, message } }, 403);
}

/**
* [#15204 W2] What an admitted mutating request carries past
* `requireInstallCapability`: the acting user, plus the two inputs ADR-0131
Expand Down Expand Up @@ -1721,6 +1761,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
// installed from timing or from a downstream storage error.
const principal = await this.resolveInstallPrincipal(c, ctx);
if (!principal) return this.refuseUnauthenticated(c);
const held = refuseHeldSession(c, principal);
if (held) return held;
const operator = principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY);

const { entries, skipped } = this.readAll();
Expand Down Expand Up @@ -3280,14 +3322,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
private resolveInstallPrincipal = async (
c: any,
ctx: PluginContext,
): Promise<{
userId: string;
systemPermissions: string[];
/** [#15204 W2] The ADR-0095 posture rung the resolver derived. */
posture?: unknown;
/** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */
tenancyPosture?: TenancyPosture;
} | null> => {
): Promise<InstallPrincipal | null> => {
const headers = c?.req?.raw?.headers;
if (!headers) return null;
try {
Expand All @@ -3301,6 +3336,13 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
let ql: IObjectQLEngine | undefined;
try { ql = ctx.getService<IObjectQLEngine>('objectql'); } catch { /* no data engine */ }

// The session user the bridge below read, kept so the ADR-0069 gate
// is asked about the SAME session the resolver admitted: one read,
// both decisions. The resolver calls the bridge only when no API
// key admitted the caller, so a key caller leaves it unset and no
// policy holds it.
let sessionUser: unknown;

// The better-auth session bridge — resolved lazily and defensively,
// exactly as the previous implementation did, then handed to the
// shared resolver instead of being trusted on its own.
Expand All @@ -3313,7 +3355,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
}
// [#22258] The in-process session-read rule, as in
// `resolveActiveOrgId` above.
return await api?.getSession?.(inProcessSessionReadInput(h));
const session = await api?.getSession?.(inProcessSessionReadInput(h));
sessionUser = session?.user;
return session;
} catch {
return undefined;
}
Expand All @@ -3325,11 +3369,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
const tenancyPosture = await this.resolveAdmissionTenancyPosture(ctx);
const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture });
if (!authz.userId) return null;
// ADR-0069: the ONE consult of the authentication-policy gate for
// all five doors, on the session read above (the posture rides its
// user, computed by plugin-auth's `customSession` enrichment only
// while a gate feature is active). No request path is passed, so
// the allow-list can grant no exemption (an empty path earns none,
// #7898); no install-local route is a remediation or bootstrap route.
const authGate = evaluateAuthGate(sessionUser, '');
return {
userId: String(authz.userId),
systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [],
posture: authz.posture,
tenancyPosture,
...(authGate ? { authGate } : {}),
};
} catch (err) {
// [commit 6a180e42d] `null` here means "nobody is authenticated", which is
Expand Down Expand Up @@ -3381,6 +3433,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
): Promise<InstallAdmission | { ok: false; response: Response }> => {
const principal = await this.resolveInstallPrincipal(c, ctx);
if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) };
const held = refuseHeldSession(c, principal);
if (held) return { ok: false, response: held };
if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) {
ctx.logger?.warn?.(
`[MarketplaceInstallLocal] refused ${action} for ${principal.userId} — `
Expand Down
Loading
Loading