|
1 | 1 | // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
2 | 2 |
|
3 | | -import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack/core'; |
| 3 | +import { |
| 4 | + Plugin, |
| 5 | + PluginContext, |
| 6 | + createPluginPermissionEnforcer, |
| 7 | + wireAuthoredTranslationSync, |
| 8 | + type PluginPermissionEnforcer, |
| 9 | +} from '@objectstack/core'; |
4 | 10 | import { resolveArtifactCollections } from './artifact-collections.js'; |
| 11 | +import { |
| 12 | + registerArtifactGrantedPermissions, |
| 13 | + type ArtifactGrantBinding, |
| 14 | +} from './security/artifact-granted-permissions.js'; |
5 | 15 | import { applyArtifactForwardConversions, assertProtocolCompat } from '@objectstack/metadata-core'; |
6 | 16 | import { resolveTenancyPosture } from '@objectstack/types'; |
7 | 17 | import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; |
@@ -125,6 +135,22 @@ export class AppPlugin implements Plugin { |
125 | 135 | * of arriving at teardown time. |
126 | 136 | */ |
127 | 137 | private initCtx?: PluginContext; |
| 138 | + /** |
| 139 | + * The enforcer holding this artifact's install-time GRANTED permission sets |
| 140 | + * (ADR-0025 F4), or `undefined` when the artifact carried no |
| 141 | + * `grantedPermissions` key — which is every artifact built before consent |
| 142 | + * existed and every `defineStack()` config, so this stays `undefined` on |
| 143 | + * every boot shape that ships today. |
| 144 | + * |
| 145 | + * Constructed in `init()` only when there is something to register, so a |
| 146 | + * boot with no consent record allocates nothing and behaves byte-for-byte |
| 147 | + * as it did. Public and readonly-by-accessor so the materialize seam that |
| 148 | + * will QUERY it — and a composition pin — can reach the registry rather |
| 149 | + * than rebuilding it from the artifact a second time. |
| 150 | + */ |
| 151 | + private grantEnforcer?: PluginPermissionEnforcer; |
| 152 | + /** What `grantedPermissions` bound to on this artifact — see {@link ArtifactGrantBinding}. */ |
| 153 | + private grantBindingResult?: ArtifactGrantBinding; |
128 | 154 | /** When true, init/start become no-ops — env has no app payload. */ |
129 | 155 | private readonly empty: boolean = false; |
130 | 156 | /** |
@@ -171,6 +197,29 @@ export class AppPlugin implements Plugin { |
171 | 197 | return (this.resolvedCollections ??= resolveArtifactCollections(this.bundle)); |
172 | 198 | } |
173 | 199 |
|
| 200 | + /** |
| 201 | + * The enforcer this artifact's consent records were registered on, or |
| 202 | + * `undefined` when the artifact declared no `grantedPermissions` key. |
| 203 | + * |
| 204 | + * ⛔ `undefined` here means "no consent record for this environment", NEVER |
| 205 | + * "denied": a caller that reads `undefined` as a deny bricks every boot |
| 206 | + * shape that ships today (clause 1.3). The three states this distinguishes |
| 207 | + * are written out on `registerArtifactGrantedPermissions`. |
| 208 | + */ |
| 209 | + get permissionEnforcer(): PluginPermissionEnforcer | undefined { |
| 210 | + return this.grantEnforcer; |
| 211 | + } |
| 212 | + |
| 213 | + /** |
| 214 | + * What the artifact's `grantedPermissions` map bound to on this boot — |
| 215 | + * `undefined` when the key was absent. Public so a composition pin can read |
| 216 | + * the binding without re-deriving it, and so a caller can tell a declared |
| 217 | + * empty map (`declared: true`, nothing registered) from an absent one. |
| 218 | + */ |
| 219 | + get grantBinding(): ArtifactGrantBinding | undefined { |
| 220 | + return this.grantBindingResult; |
| 221 | + } |
| 222 | + |
174 | 223 | constructor( |
175 | 224 | bundle: any, |
176 | 225 | projectContext?: AppPluginProjectContext, |
@@ -264,6 +313,16 @@ export class AppPlugin implements Plugin { |
264 | 313 | // empty-env early return, so teardown is armed on every path init |
265 | 314 | // takes. |
266 | 315 | this.initCtx = ctx; |
| 316 | + // Bind the install-time GRANTED permission set (ADR-0025 F4, #13457) |
| 317 | + // BEFORE anything this plugin registers on the kernel. This is the |
| 318 | + // materialize-time moment the artifact contract names as the consumer |
| 319 | + // of `EnvironmentArtifactSchema.grantedPermissions`, and it runs ahead |
| 320 | + // of the empty-env return on purpose: a consent record that binds to |
| 321 | + // nothing has to be heard on an empty environment too, which is exactly |
| 322 | + // where an artifact carrying grants for packages it does not ship shows |
| 323 | + // up. A no-op — not even an allocation — on every artifact that carries |
| 324 | + // no `grantedPermissions` key. |
| 325 | + this.bindGrantedPermissions(ctx); |
267 | 326 | // Install the engine-wide default hook body runner FIRST — even for |
268 | 327 | // empty envs (an empty env is exactly where a user will author their |
269 | 328 | // first Studio hook). Runs in init (Phase 1) so it is in place before |
@@ -326,6 +385,38 @@ export class AppPlugin implements Plugin { |
326 | 385 | ctx.getService<{ register(m: any): void }>('manifest').register(servicePayload); |
327 | 386 | } |
328 | 387 |
|
| 388 | + /** |
| 389 | + * Register the install-time GRANTED permission set this artifact carries, |
| 390 | + * one entry per consent-bearing package, on an enforcer this plugin owns |
| 391 | + * (ADR-0025 F4 / #13457 — the consumer half of |
| 392 | + * `EnvironmentArtifactSchema.grantedPermissions`). |
| 393 | + * |
| 394 | + * The whole method is behind the `=== undefined` gate below, and that gate |
| 395 | + * is the clause-1.3 guarantee in code: an artifact with no consent record |
| 396 | + * takes no branch, allocates no enforcer and registers nothing, so ABSENT |
| 397 | + * can never become "denied". `{}` is not absent and does not take the early |
| 398 | + * return — a declared-but-empty map is a consent record that names no |
| 399 | + * package, which is a different reading and is recorded as one. |
| 400 | + * |
| 401 | + * ⛔ Never `??`/`||` on `grantedPermissions`: both spellings turn a declared |
| 402 | + * `{}` into absence and erase a distinction the producer pins both ways. |
| 403 | + */ |
| 404 | + private bindGrantedPermissions(ctx: PluginContext): void { |
| 405 | + if ((this.bundle as { grantedPermissions?: unknown } | null | undefined)?.grantedPermissions === undefined) { |
| 406 | + return; |
| 407 | + } |
| 408 | + const enforcer = createPluginPermissionEnforcer(ctx.logger); |
| 409 | + const binding = registerArtifactGrantedPermissions(this.bundle, enforcer, { logger: ctx.logger }); |
| 410 | + this.grantEnforcer = enforcer; |
| 411 | + this.grantBindingResult = binding; |
| 412 | + ctx.logger.info('[AppPlugin] registered install-time granted permissions', { |
| 413 | + pluginName: this.name, |
| 414 | + registered: [...binding.registered], |
| 415 | + unregistered: [...binding.unregistered], |
| 416 | + unbound: [...binding.unbound], |
| 417 | + }); |
| 418 | + } |
| 419 | + |
329 | 420 | /** |
330 | 421 | * Seed persisted package disable-state into the registry's initial-disabled |
331 | 422 | * set, so every later registration path — boot artifact decomposition, |
|
0 commit comments