Skip to content

Commit 09bfe11

Browse files
committed
fix(runtime,rest,cloud-connection)!: consult the ADR-0069 gate at the declared-endpoint fallback, the REST direct mounts and the cloud-connection session doors
A session the authentication policy holds (an expired password, enforced MFA not yet enrolled) is now refused with 403 and the gate's own code at three door groups that resolved a session without asking the gate: - runtime: the declared-endpoint fallback's policy chain asks evaluateAuthGate, with no request path, before authRequired, on a dedicated session read that runs only while a gate feature is active and answers 503 when an active gate cannot read the session. - rest: the publish door and the federation floor read the authGate their resolved execution context already carries. - cloud-connection: CloudConnectionPlugin's session resolution and MarketplaceInstallLocalPlugin's admission consult the gate once and refuse before any door logic. Claude-Session: https://claude.ai/code/session_01VoSxBQujKLZKPwK2u5ehQ6 Co-authored-by: Claude <noreply@anthropic.com>
1 parent 150da61 commit 09bfe11

6 files changed

Lines changed: 312 additions & 32 deletions

File tree

‎packages/cloud-connection/src/cloud-connection-plugin.ts‎

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,10 @@
2323
* `apps/objectos` (multi-tenant) and `apps/objectos-ee` (single-environment)
2424
* — wire the same canonical implementation. cloud ADR-0008 Phase 2 moves this
2525
* surface into the open `@objectstack/cloud-connection` package; keep this
26-
* file dependency-light (structural Plugin types, no @objectstack/core) so
27-
* the move is mechanical.
26+
* file dependency-light (structural Plugin types) so the move is mechanical.
27+
* The one value it takes from `@objectstack/core` is the ADR-0069 gate's
28+
* decision, `evaluateAuthGate`: a second copy of a security decision is the
29+
* defect that function exists to prevent (see `sessionFromAuthService`).
2830
*
2931
* ## Environment & session resolution — two modes
3032
*
@@ -50,7 +52,7 @@
5052
* self-hosted runtimes).
5153
*/
5254

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

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

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

90+
/**
91+
* A signed-in caller of this surface, and the ADR-0069 posture its session
92+
* carries (`authGate`, present only for a session the policy holds).
93+
*/
94+
interface ResolvedSession {
95+
userId: string;
96+
authGate?: AuthGate;
97+
}
98+
8799
/**
88100
* The one message every `ENVIRONMENT_NOT_FOUND` refusal on this surface carries.
89101
*
@@ -201,17 +213,40 @@ export class CloudConnectionPlugin implements Plugin {
201213
manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE);
202214
} catch { /* no manifest service — headless kernel */ }
203215

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

214-
const resolveSession = async (environmentId: string, rawReq: Request): Promise<{ userId?: string } | null> => {
238+
/**
239+
* ADR-0069: a session the authentication policy HOLDS (an expired
240+
* password, enforced MFA not yet enrolled) is gated out of every
241+
* protected resource until it remediates. Every door that resolves
242+
* a session answers it this way, before its own 401 and before any
243+
* control-plane call: REST's answer (`403` with the gate's own
244+
* `code` and `message`), in this surface's envelope.
245+
*/
246+
const refuseHeldSession = (c: any, gate: AuthGate) =>
247+
c.json({ success: false, error: { code: gate.code, message: gate.message } }, 403);
248+
249+
const resolveSession = async (environmentId: string, rawReq: Request): Promise<ResolvedSession | null> => {
215250
try {
216251
if (this.cfg.singleEnvironment) {
217252
// Single-env: the host kernel owns auth.
@@ -294,6 +329,7 @@ export class CloudConnectionPlugin implements Plugin {
294329
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
295330
}
296331
const session = await resolveSession(environmentId ?? '', c.req.raw);
332+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
297333
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to connect a cloud account.' } }, 401);
298334
if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503);
299335
try {
@@ -349,6 +385,7 @@ export class CloudConnectionPlugin implements Plugin {
349385
return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404);
350386
}
351387
const session = await resolveSession(environmentId ?? '', c.req.raw);
388+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
352389
if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
353390
if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503);
354391

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

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

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

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

646687
const session = await resolveSession(environmentId ?? '', c.req.raw);
688+
if (session?.authGate) return refuseHeldSession(c, session.authGate);
647689
if (!session?.userId) {
648690
return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401);
649691
}

‎packages/cloud-connection/src/marketplace-install-local-plugin.ts‎

Lines changed: 71 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,10 @@ import type { Plugin, PluginContext } from '@objectstack/core';
112112
import {
113113
resolveAuthzContext,
114114
isAuthzStoreUnavailableError,
115+
// ADR-0069 — the authentication-policy gate's ONE decision, asked of the
116+
// session `resolveInstallPrincipal` reads (see `admitInstallCaller`).
117+
evaluateAuthGate,
118+
type AuthGate,
115119
// [#16013] The ADMISSION seam's posture derivation, in ONE call — the same
116120
// symbol `packages/rest`'s seam now imports, for the same reason. It reads
117121
// the posture IN FORCE off the kernel's `tenancy` service and carries
@@ -529,6 +533,25 @@ function organizationWallActive(ctx: PluginContext): boolean {
529533
const NO_ACTIVE_ORGANIZATION_CODE = 'PERMISSION_DENIED';
530534
const NO_ACTIVE_ORGANIZATION_STATUS = 403;
531535

536+
/**
537+
* The caller one install-local admission resolved: identity and grants off the
538+
* shared resolver, plus the two postures the doors read.
539+
*/
540+
interface InstallPrincipal {
541+
userId: string;
542+
systemPermissions: string[];
543+
/** [#15204 W2] The ADR-0095 posture rung the resolver derived. */
544+
posture?: unknown;
545+
/** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */
546+
tenancyPosture?: TenancyPosture;
547+
/**
548+
* ADR-0069: the authentication-policy posture of the session this
549+
* admission resolved, present only for a session the policy holds.
550+
* `admitInstallCaller` refuses on it.
551+
*/
552+
authGate?: AuthGate;
553+
}
554+
532555
/**
533556
* [#15204 W2] What an admitted mutating request carries past
534557
* `requireInstallCapability`: the acting user, plus the two inputs ADR-0131
@@ -1719,8 +1742,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
17191742
// Before the ledger is touched, exactly as the mutating doors refuse
17201743
// before any work: a refused caller must not be able to learn what is
17211744
// installed from timing or from a downstream storage error.
1722-
const principal = await this.resolveInstallPrincipal(c, ctx);
1723-
if (!principal) return this.refuseUnauthenticated(c);
1745+
const admitted = await this.admitInstallCaller(c, ctx);
1746+
if (!admitted.ok) return admitted.response;
1747+
const { principal } = admitted;
17241748
const operator = principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY);
17251749

17261750
const { entries, skipped } = this.readAll();
@@ -3280,14 +3304,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
32803304
private resolveInstallPrincipal = async (
32813305
c: any,
32823306
ctx: PluginContext,
3283-
): Promise<{
3284-
userId: string;
3285-
systemPermissions: string[];
3286-
/** [#15204 W2] The ADR-0095 posture rung the resolver derived. */
3287-
posture?: unknown;
3288-
/** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */
3289-
tenancyPosture?: TenancyPosture;
3290-
} | null> => {
3307+
): Promise<InstallPrincipal | null> => {
32913308
const headers = c?.req?.raw?.headers;
32923309
if (!headers) return null;
32933310
try {
@@ -3301,6 +3318,13 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33013318
let ql: IObjectQLEngine | undefined;
33023319
try { ql = ctx.getService<IObjectQLEngine>('objectql'); } catch { /* no data engine */ }
33033320

3321+
// The session user the bridge below read, kept so the ADR-0069 gate
3322+
// is asked about the SAME session the resolver admitted: one read,
3323+
// both decisions. The resolver calls the bridge only when no API
3324+
// key admitted the caller, so a key caller leaves it unset and no
3325+
// policy holds it.
3326+
let sessionUser: unknown;
3327+
33043328
// The better-auth session bridge — resolved lazily and defensively,
33053329
// exactly as the previous implementation did, then handed to the
33063330
// shared resolver instead of being trusted on its own.
@@ -3313,7 +3337,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33133337
}
33143338
// [#22258] The in-process session-read rule, as in
33153339
// `resolveActiveOrgId` above.
3316-
return await api?.getSession?.(inProcessSessionReadInput(h));
3340+
const session = await api?.getSession?.(inProcessSessionReadInput(h));
3341+
sessionUser = session?.user;
3342+
return session;
33173343
} catch {
33183344
return undefined;
33193345
}
@@ -3325,11 +3351,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33253351
const tenancyPosture = await this.resolveAdmissionTenancyPosture(ctx);
33263352
const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture });
33273353
if (!authz.userId) return null;
3354+
// ADR-0069: the ONE consult of the authentication-policy gate for
3355+
// all five doors, on the session read above (the posture rides its
3356+
// user, computed by plugin-auth's `customSession` enrichment only
3357+
// while a gate feature is active). No request path is passed, so
3358+
// the allow-list can grant no exemption (an empty path earns none,
3359+
// #7898); no install-local route is a remediation or bootstrap route.
3360+
const authGate = evaluateAuthGate(sessionUser, '');
33283361
return {
33293362
userId: String(authz.userId),
33303363
systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [],
33313364
posture: authz.posture,
33323365
tenancyPosture,
3366+
...(authGate ? { authGate } : {}),
33333367
};
33343368
} catch (err) {
33353369
// [commit 6a180e42d] `null` here means "nobody is authenticated", which is
@@ -3355,6 +3389,29 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33553389
error: { code: 'UNAUTHENTICATED', message: 'Authentication required.' },
33563390
}, 401);
33573391

3392+
/**
3393+
* ADR-0069: the floor all five doors open with, the listing included.
3394+
* Nobody authenticated is `401` ({@link refuseUnauthenticated}). A session
3395+
* the authentication policy HOLDS (an expired password, enforced MFA not
3396+
* yet enrolled) is gated out of every protected resource until it
3397+
* remediates, so it gets REST's answer: `403` with the gate's own `code`
3398+
* and `message`, in this plugin's envelope, before its capabilities are
3399+
* consulted and before the ledger is read. One helper for the same reason
3400+
* the 401 is one literal: a door added later cannot copy a weaker answer.
3401+
*/
3402+
private admitInstallCaller = async (
3403+
c: any,
3404+
ctx: PluginContext,
3405+
): Promise<{ ok: true; principal: InstallPrincipal } | { ok: false; response: Response }> => {
3406+
const principal = await this.resolveInstallPrincipal(c, ctx);
3407+
if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) };
3408+
if (principal.authGate) {
3409+
const { code, message } = principal.authGate;
3410+
return { ok: false, response: c.json({ success: false, error: { code, message } }, 403) };
3411+
}
3412+
return { ok: true, principal };
3413+
};
3414+
33583415
/**
33593416
* [#8976] The shared refusal for the four mutating routes: 401 when nobody
33603417
* is authenticated, 403 when somebody is but holds no authoring capability,
@@ -3379,8 +3436,9 @@ export class MarketplaceInstallLocalPlugin implements Plugin {
33793436
ctx: PluginContext,
33803437
action: string,
33813438
): Promise<InstallAdmission | { ok: false; response: Response }> => {
3382-
const principal = await this.resolveInstallPrincipal(c, ctx);
3383-
if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) };
3439+
const admitted = await this.admitInstallCaller(c, ctx);
3440+
if (!admitted.ok) return admitted;
3441+
const { principal } = admitted;
33843442
if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) {
33853443
ctx.logger?.warn?.(
33863444
`[MarketplaceInstallLocal] refused ${action} for ${principal.userId} — `

0 commit comments

Comments
 (0)