Skip to content

feat(self-update): the bundle client presents its key only to a declared artifact host (#4123, core half) - #6378

Merged
rbuergi merged 4 commits into
mainfrom
feat/4123-bundle-artifact-trust
Oct 9, 2026
Merged

rbuergi merged 4 commits into
mainfrom
feat/4123-bundle-artifact-trust

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

Summary

Maintainer decision on #4123: "derive the self-update registry/validator pairing on control at render time from one declaration." Recorded as policy registry-trust-one-declaration in the register.

This is the core half: the second trust rule the issue names. The self-update half (control instance derives selfUpdate.registryValidationUrl) is in MeshWeaver.Plugins (paired PR below). Neither half depends on the other to compile, and the chart already renders selfUpdate.registryValidationUrl (#4094).

Before: PluginBundleClient.DownloadArtifact sent the instance key to whatever OCI host a bundle entry's artifact named. A publisher writes that entry.
After: the key for registry mount M goes to artifact host H only when H is M's own host, or M's bundle index declares H (artifactRegistry).

  • The client never contacts an undeclared host. It fetches the bytes over M's own HTTP bundle route instead, so every install that adopts today keeps adopting. The decision is made before any request is sent.
  • artifactRegistry is index-level and comes from the registry's own config (PluginBundles:ArtifactRegistry). The chart renders that key from the hosted registry's own registry.host (PluginBundles__ArtifactRegistry, only when registry.enabled). A bundle entry cannot earn the key.
  • The rule is keyed on the mount, never on SelfUpdate:Registry. The control instance pulls its image from ACR but adopts bundles sealed on cr.meshweaver.cloud.
  • Hosts are compared whole and case-insensitively. A suffix-extended host, a parent domain or another port is a different host.
  • BundleIndex.ArtifactRegistry is an init property, so the record's primary constructor is unchanged (no binary break).

Tests (Release, -warnaserror clean: Memex.Portal.Shared.Test, MeshWeaver.Documentation)

  • PluginBundleArtifactFetchTest: 14/14.
    • New theory AnUndeclaredArtifactHost_NeverReceivesTheKey_AndTheHttpRouteServes, three cases: no declaration, another host, a suffix-extended host. In each, the OCI fake receives zero requests and no presented secret, and the bundle lands over HTTP.
    • The existing AnAdvertisedArtifact_IsFetchedFromTheOciRegistry… is the positive control (declared host gets the key).
    • New pure test TheArtifactKeyTarget_…: both directions.
    • Negative control: with the rule disabled (if (false && …)), the new theory goes 3/3 red. Restored.
  • PluginBundleIndexArtifactTest: 4/4. New TheIndexStatesTheRegistrysOwnArtifactRegistry_AndNullWhenUndeclared covers both arms.
  • check-chart-invariants.sh: 20 combinations and 10 refusal controls hold.

Rollout / what goes live when

  • Registry side: memex-cloud (hosts cr.meshweaver.cloud) needs a re-render on a chart and image carrying this change: a governed Reconcile after the roll. Then its index carries artifactRegistry: cr.meshweaver.cloud.
  • Consumers: if a consumer gets the new client before the registry declares the host, it fetches artifacts over HTTP. That is slower, never broken, and the key is not sent to the undeclared host. Verify on a consumer's log: Bundle for X@v: N byte(s) fetched from cr.meshweaver.cloud/…, digest-verified once the registry declares the host. Before that, the line is … declares no artifact registry … taking …'s HTTP bundle route instead.
  • No recycle is owed by this half. It changes the image and the chart only.

Pairs-with: none — no public type or member is removed (BundleIndex gains an init property; Download is private)
Implementers: none — no interface member added
Mirror-sync: none — no i18n catalog change

Refs #4123

🤖 Generated with Claude Code

… presents its key only to a declared artifact host (#4123)

The key a consumer holds for registry mount M now goes to an artifact's OCI host H only when H is
M's own host or M's bundle index declares it (index-level `artifactRegistry`, the registry's own
`registry.host`, rendered by the chart as PluginBundles__ArtifactRegistry). An undeclared host is
never contacted; the bytes come over the registry's HTTP route, so installs keep adopting.
Previously the key followed whatever host a publisher-written bundle entry named.

Policy `registry-trust-one-declaration` in the register; SelfUpdateRegistryCredential and
SelfUpdateControlLane describe the rule. The self-update half (HelmValues derives
selfUpdate.registryValidationUrl on the control instance) lands in MeshWeaver.Plugins.

Refs #4123

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 9, 2026 19:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The current host validation still permits credential disclosure through hostile OCI challenge realms and userinfo-bearing host values.

1 open finding
What changed in this PR

Introduces registry-owned artifact-host trust for plugin bundle credentials, with safe HTTP fallback.

Changes:

  • Adds artifactRegistry to bundle indexes and chart configuration.
  • Restricts OCI artifact fetching to declared hosts.
  • Adds tests and updates architecture policy documentation.
File Description
src/​MeshWeaver.PluginCatalog/​PluginBundleClient.cs Implements artifact-host authorization and fallback.
memex/​Memex.Portal.Shared/​Api/​PluginBundleEndpoints.cs Publishes the configured artifact registry.
deploy/​helm/​templates/​memex-portal/​config.yaml Renders the registry-owned declaration.
test/​Memex.Portal.Shared.Test/​PluginBundleArtifactFetchTest.cs Tests declared and undeclared artifact hosts.
test/​Memex.Portal.Shared.Test/​PluginBundleIndexArtifactTest.cs Tests index declaration output.
src/​MeshWeaver.Documentation/​Data/​Architecture/​SelfUpdateRegistryCredential.md Documents the unified trust model.
src/​MeshWeaver.Documentation/​Data/​Architecture/​SelfUpdateControlLane.md Records the implemented design.
src/​MeshWeaver.Documentation/​Data/​Architecture/​PolicyNotProse.md Registers the new policy.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

pluginId, bundle.Version, refusal, _registryUrl);
return DownloadOverHttp(pluginId, bundle.Version);
}
return DownloadArtifact(pluginId, bundle.Version, artifact);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real defect, fixed at the root in f50faaf (pushed with a merge of main as 17d8bca). OciRegistryClient.ExchangeAsync now presents Basic instance:<key> at the token realm only when the realm's authority AND scheme equal the registry's own. Otherwise it raises RegistryRefusedException before anything is sent there. The approved artifact host is the client's registry, so this is the same check the thread asks for, and it also covers the self-updater's OciTagLister. Every fleet realm is same-host: cr.meshweaver.cloud/auth and meshweaver.azurecr.io/oauth2/token, read live from the /v2/ challenges.

Regression: OciRegistryClientTest.ATokenRealmOffTheRegistrysOwnAuthority_NeverReceivesTheKey, three cases: another host, a suffix-extended host, and an http downgrade. In each, the hostile fake gets zero requests and no presented secret. With the authority check disabled, both host cases go red. OciRegistryClientTest, OciTagListerTest and PluginBundleArtifactFetchTest: 60/60. Release -warnaserror clean.

rbuergi and others added 3 commits October 9, 2026 21:39
…'s own authority and scheme (#4123)

The Bearer challenge's realm is named by the registry's 401, and ExchangeAsync sent Basic
instance:<key> to an absolute realm on any host. A registry approved for the key could redirect it.
The realm must now match the registry's own authority and scheme, else RegistryRefusedException is
raised before anything is sent. Every fleet realm is same-host (cr.meshweaver.cloud/auth,
meshweaver.azurecr.io/oauth2/token). Hostile-realm regression test, three cases.

Refs #4123

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…fact-trust

# Conflicts:
#	src/MeshWeaver.Documentation/Data/Architecture/PolicyNotProse.md
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Test Results

    24 files  ±0      24 suites  ±0   50m 27s ⏱️ +17s
11 295 tests +8  11 102 ✅ +8  193 💤 ±0  0 ❌ ±0 
11 308 runs  +8  11 115 ✅ +8  193 💤 ±0  0 ❌ ±0 

Results for commit bea4786. ± Comparison against base commit f7853c5.

This pull request removes 40 and adds 24 tests. Note that renamed tests count towards both.

   --- End of inner exception stack trace ---
   --- End of inner exception stack trace ---, expected: True)
   --- End of inner exception stack trace ---, isDenial: True)
 ---> (Inner Exception #1) MeshWeaver.Mesh.QueryProviderStalledException: Query provider(s) [pg] did not emit an Initial within the query fan-in's 16s bound for query 'nodeType:NodeType' (user 'system'). The merged Initial gates on EVERY provider, so this query has NO snapshot to answer with — it is reported as unavailable (retryable) rather than left hanging with no error. This is an availability failure, never a permission verdict: a consumer deciding access must fail CLOSED and say it could not establish the answer. Fix the stalled provider; never bump the consumer's timeout.<---
 ---> (Inner Exception #1) MeshWeaver.Messaging.Hub.Test.InfrastructureFaultTest+ProviderException (0x80004005): Failed to connect to 10.42.18.4:5432<---
 ---> (Inner Exception #1) System.ArgumentException: Value does not fall within the expected range.<---
 ---> (Inner Exception #1) System.InvalidOperationException: boom<---
 ---> (Inner Exception #1) System.InvalidOperationException: source B is misconfigured<---
 ---> (Inner Exception #1) System.Net.Sockets.SocketException (0xFFFDFFFF): Name or service not known<---
…
Memex.Portal.Shared.Test.InstanceIdRulesMatchTheRegistryTest ‑ TheSetupHostAgreesWithTheRegistry(candidate: "b9dd3f48-0ae9-4ac5-8de1-fe8c51dc811f")
Memex.Portal.Shared.Test.OciRegistryClientTest ‑ ATokenRealmOffTheRegistrysOwnAuthority_NeverReceivesTheKey(realm: "http://cr.example.test/v2/token")
Memex.Portal.Shared.Test.OciRegistryClientTest ‑ ATokenRealmOffTheRegistrysOwnAuthority_NeverReceivesTheKey(realm: "https://cr.example.test.evil.example/v2/token")
Memex.Portal.Shared.Test.OciRegistryClientTest ‑ ATokenRealmOffTheRegistrysOwnAuthority_NeverReceivesTheKey(realm: "https://evil.example/v2/token")
Memex.Portal.Shared.Test.PluginBundleArtifactFetchTest ‑ AnUndeclaredArtifactHost_NeverReceivesTheKey_AndTheHttpRouteServes(declared: "cr.artifact.test.evil")
Memex.Portal.Shared.Test.PluginBundleArtifactFetchTest ‑ AnUndeclaredArtifactHost_NeverReceivesTheKey_AndTheHttpRouteServes(declared: "cr.someone-else.test")
Memex.Portal.Shared.Test.PluginBundleArtifactFetchTest ‑ AnUndeclaredArtifactHost_NeverReceivesTheKey_AndTheHttpRouteServes(declared: null)
Memex.Portal.Shared.Test.PluginBundleArtifactFetchTest ‑ TheArtifactKeyTarget_IsTheRegistrysOwnHost_OrItsDeclaredArtifactRegistry
Memex.Portal.Shared.Test.PluginBundleIndexArtifactTest ‑ TheIndexStatesTheRegistrysOwnArtifactRegistry_AndNullWhenUndeclared
Memex.Portal.Shared.Test.SessionDenialIsAnAnswerTest ‑ OnlyAVerdictReadsAsADenial(shape: "the same verdict nested, as a late denial dispatch"···, failure: System.InvalidOperationException: write failed
 ---> System.UnauthorizedAccessException: Access denied
   --- End of inner exception stack trace ---, isDenial: True)
…

♻️ This comment has been updated with latest results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants