|
23 | 23 | * `apps/objectos` (multi-tenant) and `apps/objectos-ee` (single-environment) |
24 | 24 | * — wire the same canonical implementation. cloud ADR-0008 Phase 2 moves this |
25 | 25 | * 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`). |
28 | 30 | * |
29 | 31 | * ## Environment & session resolution — two modes |
30 | 32 | * |
|
50 | 52 | * self-hosted runtimes). |
51 | 53 | */ |
52 | 54 |
|
53 | | -// Minimal structural plugin contract — keep this module independent of |
| 55 | +// Minimal structural plugin contract — keep this module's TYPES independent of |
54 | 56 | // @objectstack/core so the cloud ADR-0008 Phase 2 move to the open package stays |
55 | 57 | // mechanical (mirrors the other plugins in this package). |
56 | 58 | interface PluginContext { |
@@ -78,12 +80,22 @@ interface Plugin { |
78 | 80 | } |
79 | 81 |
|
80 | 82 | import { hostname } from 'node:os'; |
| 83 | +import { evaluateAuthGate, type AuthGate } from '@objectstack/core'; |
81 | 84 | import { inProcessSessionReadInput } from '@objectstack/types'; |
82 | 85 | import { ConnectionCredentialStore } from './connection-credential-store.js'; |
83 | 86 | import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; |
84 | 87 |
|
85 | 88 | const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection'; |
86 | 89 |
|
| 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 | + |
87 | 99 | /** |
88 | 100 | * The one message every `ENVIRONMENT_NOT_FOUND` refusal on this surface carries. |
89 | 101 | * |
@@ -201,17 +213,40 @@ export class CloudConnectionPlugin implements Plugin { |
201 | 213 | manifest?.register?.(CLOUD_CONNECTION_UI_BUNDLE); |
202 | 214 | } catch { /* no manifest service — headless kernel */ } |
203 | 215 |
|
204 | | - const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<{ userId?: string } | null> => { |
| 216 | + const sessionFromAuthService = async (authSvc: any, rawReq: Request): Promise<ResolvedSession | null> => { |
205 | 217 | const api = typeof authSvc?.getApi === 'function' ? await authSvc.getApi() : authSvc?.api ?? authSvc; |
206 | 218 | // [#22258] The in-process session-read rule: a request carrying a |
207 | 219 | // session cookie reads without renewal, because this route's |
208 | 220 | // response never carries a renewed cookie. |
209 | 221 | const session = await api?.getSession?.(inProcessSessionReadInput(rawReq.headers)); |
210 | 222 | 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 }; |
212 | 236 | }; |
213 | 237 |
|
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> => { |
215 | 250 | try { |
216 | 251 | if (this.cfg.singleEnvironment) { |
217 | 252 | // Single-env: the host kernel owns auth. |
@@ -294,6 +329,7 @@ export class CloudConnectionPlugin implements Plugin { |
294 | 329 | return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); |
295 | 330 | } |
296 | 331 | const session = await resolveSession(environmentId ?? '', c.req.raw); |
| 332 | + if (session?.authGate) return refuseHeldSession(c, session.authGate); |
297 | 333 | if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to connect a cloud account.' } }, 401); |
298 | 334 | if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503); |
299 | 335 | try { |
@@ -349,6 +385,7 @@ export class CloudConnectionPlugin implements Plugin { |
349 | 385 | return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); |
350 | 386 | } |
351 | 387 | const session = await resolveSession(environmentId ?? '', c.req.raw); |
| 388 | + if (session?.authGate) return refuseHeldSession(c, session.authGate); |
352 | 389 | if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); |
353 | 390 | if (!cloudUrl) return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'No cloud control plane configured.' } }, 503); |
354 | 391 |
|
@@ -447,6 +484,7 @@ export class CloudConnectionPlugin implements Plugin { |
447 | 484 | return c.json({ success: false, error: { code: 'ENVIRONMENT_NOT_FOUND', message: ENVIRONMENT_NOT_FOUND_MESSAGE } }, 404); |
448 | 485 | } |
449 | 486 | const session = await resolveSession(environmentId ?? '', c.req.raw); |
| 487 | + if (session?.authGate) return refuseHeldSession(c, session.authGate); |
450 | 488 | if (!session?.userId) return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); |
451 | 489 |
|
452 | 490 | // Revoke cloud-side FIRST (the oscc_ bearer self-identifies; |
@@ -495,6 +533,7 @@ export class CloudConnectionPlugin implements Plugin { |
495 | 533 | // explicit env-admin role check; today only owners/admins obtain |
496 | 534 | // a session via SSO-as-owner.) |
497 | 535 | const session = await resolveSession(environmentId, c.req.raw); |
| 536 | + if (session?.authGate) return refuseHeldSession(c, session.authGate); |
498 | 537 | if (!session?.userId) { |
499 | 538 | return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment to install apps.' } }, 401); |
500 | 539 | } |
@@ -545,6 +584,7 @@ export class CloudConnectionPlugin implements Plugin { |
545 | 584 | } |
546 | 585 |
|
547 | 586 | const session = await resolveSession(environmentId, c.req.raw); |
| 587 | + if (session?.authGate) return refuseHeldSession(c, session.authGate); |
548 | 588 | if (!session?.userId) { |
549 | 589 | return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); |
550 | 590 | } |
@@ -607,6 +647,7 @@ export class CloudConnectionPlugin implements Plugin { |
607 | 647 | } |
608 | 648 |
|
609 | 649 | const session = await resolveSession(environmentId, c.req.raw); |
| 650 | + if (session?.authGate) return refuseHeldSession(c, session.authGate); |
610 | 651 | if (!session?.userId) { |
611 | 652 | return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); |
612 | 653 | } |
@@ -644,6 +685,7 @@ export class CloudConnectionPlugin implements Plugin { |
644 | 685 | } |
645 | 686 |
|
646 | 687 | const session = await resolveSession(environmentId ?? '', c.req.raw); |
| 688 | + if (session?.authGate) return refuseHeldSession(c, session.authGate); |
647 | 689 | if (!session?.userId) { |
648 | 690 | return c.json({ success: false, error: { code: 'UNAUTHENTICATED', message: 'Sign in to this environment.' } }, 401); |
649 | 691 | } |
|
0 commit comments