diff --git a/deploy/homebrew/bin/memex-local b/deploy/homebrew/bin/memex-local index d314bdfed4..e10b59eddd 100755 --- a/deploy/homebrew/bin/memex-local +++ b/deploy/homebrew/bin/memex-local @@ -347,7 +347,8 @@ cmd_registry() { info " memex-local registry $url --key mwr_…" fi info "Apply it: memex-local update (pulls the CI-built image from $ACR and re-renders the chart;" - info " the portal registers itself on first boot and installs what it is granted)." + info " after a platform admin accepts the privacy statement and platform terms in Settings ▸ + Instance registration, the portal registers itself and installs what it is granted)." ;; esac } diff --git a/memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs b/memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs index 5351bd0fd5..81a8481665 100644 --- a/memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs +++ b/memex/Memex.Portal.Shared/Api/InstanceRegistrationEndpoints.cs @@ -61,7 +61,10 @@ private static Task Register( body.BootstrapKey, body.InstanceId, body.DisplayName, body.Description, body.HomeUrl)) .Select(registration => (IResult)Results.Json( new InstanceRegistrationPayloads.Response( - registration.Instance.InstanceId, registration.RawKey), + registration.Instance.InstanceId, registration.RawKey) + { + Plan = registration.Instance.Plan ?? PlanTierRanks.BaselinePlan, + }, InstanceRegistrationPayloads.Json)) .Catch((InvalidBootstrapKeyException ex) => { diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-an-installation-asks-before-it-registers.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-an-installation-asks-before-it-registers.md new file mode 100644 index 0000000000..b250819a06 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-08-30-an-installation-asks-before-it-registers.md @@ -0,0 +1,11 @@ +--- +Name: An installation asks before it registers +Category: Feature +Description: A new installation registers itself at the plugin registry only after a platform admin has read and accepted the privacy statement and the platform terms in Settings ▸ Instance registration — then it registers, obtains its token and shows the catalogue its plan covers +Icon: Sparkle +Order: -20260830 +--- + +# An installation asks before it registers + +A new installation that is configured to register itself at a plugin registry without a key — the Homebrew default — no longer does so silently on first start. It starts, and asks: a platform admin opens **Settings ▸ Instance registration**, reads the privacy statement and the platform terms shown there, accepts both, and registers. The consent is recorded on the installation (who, when, which version of each text); only then does the installation register, obtain its short-lived token and read the package catalogue its plan covers — which the same page shows, together with the instance id and the plan it landed on (free, unless the registry's admins raise it). Withdrawing the consent from the same page stops the installation from registering again. Installations provisioned with a registration key by an operator are unaffected. diff --git a/src/MeshWeaver.Graph/Configuration/MeshWeaverInstanceNodeType.cs b/src/MeshWeaver.Graph/Configuration/MeshWeaverInstanceNodeType.cs index cc040570ca..1aae09b49c 100644 --- a/src/MeshWeaver.Graph/Configuration/MeshWeaverInstanceNodeType.cs +++ b/src/MeshWeaver.Graph/Configuration/MeshWeaverInstanceNodeType.cs @@ -45,6 +45,21 @@ public static class MeshWeaverInstanceNodeType /// with instance-key indexes — content type tells them apart. public const string RegistrationKeyNodeType = "RegistrationKey"; + /// The node-type identifier for the CONSUMER's consent record + /// () — the privacy statement and platform terms a platform admin + /// of this installation accepted before it registers itself at a registry. + public const string ConsentNodeType = "InstanceConsent"; + + /// The consent record's namespace: the Admin partition, so only a global admin can + /// give (or withdraw, by deleting it) the deployment's consent. + public const string ConsentNamespace = "Admin"; + + /// The consent record's id — one per instance. + public const string ConsentId = "InstanceConsent"; + + /// Path of the consent record. + public const string ConsentPath = $"{ConsentNamespace}/{ConsentId}"; + /// /// Registers both node types on the mesh builder and puts their content types in the hub's /// type registry so they serialize across silos (without this, a cross-silo create fails with @@ -53,20 +68,35 @@ public static class MeshWeaverInstanceNodeType public static TBuilder AddMeshWeaverInstanceType(this TBuilder builder) where TBuilder : MeshBuilder { - builder.AddMeshNodes(CreateMeshNode(), CreateGrantMeshNode(), CreateRegistrationKeyMeshNode()); + builder.AddMeshNodes( + CreateMeshNode(), CreateGrantMeshNode(), CreateRegistrationKeyMeshNode(), CreateConsentMeshNode()); // An instance is infrastructure identity, not content — keep all of these out of // autocomplete so they never surface as pickable nodes in the composer. - builder.AddAutocompleteExcludedTypes(NodeType, GrantNodeType, RegistrationKeyNodeType); + builder.AddAutocompleteExcludedTypes(NodeType, GrantNodeType, RegistrationKeyNodeType, ConsentNodeType); builder.ConfigureHub(config => config .WithType(nameof(MeshWeaverInstance)) .WithType(nameof(MeshWeaverInstanceIndex)) .WithType(nameof(PluginGrant)) .WithType(nameof(PluginGrantEntry)) .WithType(nameof(RegistrationKey)) - .WithType(nameof(RegistrationKeyIndex))); + .WithType(nameof(RegistrationKeyIndex)) + .WithType(nameof(InstanceConsent))); return builder; } + /// The node definition. One record in the Admin + /// partition; the partition's access control is what keeps everyone but a global admin from + /// consenting on the deployment's behalf. + public static MeshNode CreateConsentMeshNode() => new(ConsentNodeType) + { + Name = "Instance Consent", + IsSatelliteType = false, + ExcludeFromContext = new HashSet { "search", "create", "content" }, + HubConfiguration = config => config + .AddMeshDataSource(source => source + .WithContentType()) + }; + /// The node definition (instances + their index). public static MeshNode CreateMeshNode() => new(NodeType) { diff --git a/src/MeshWeaver.Mesh.Contract/Security/InstanceConsent.cs b/src/MeshWeaver.Mesh.Contract/Security/InstanceConsent.cs new file mode 100644 index 0000000000..a3689d9f16 --- /dev/null +++ b/src/MeshWeaver.Mesh.Contract/Security/InstanceConsent.cs @@ -0,0 +1,53 @@ +namespace MeshWeaver.Mesh.Security; + +/// +/// The consent that gates an installation's OPEN registration at a registry: a platform admin of +/// this instance has read the privacy statement and the platform terms and accepted both, on a +/// date, under a name. Until this record exists the instance does not register itself, does not +/// obtain a token, and does not pull a catalogue — it starts, and asks. +/// +/// One record per instance, at Admin/InstanceConsent: consent is a property of the +/// DEPLOYMENT, given once by someone entitled to speak for it (a global admin), not of a user. The +/// texts are recorded by HASH so an audit can tell WHICH statement and WHICH terms were accepted +/// after either has been edited; a later edit does not revoke the consent — the operator who +/// changes the terms decides whether to ask again, by deleting this record. +/// +/// Only OPEN registration (no bootstrap key) is gated. An instance provisioned with a +/// registration key was set up by an operator who accepted the terms on the fleet's side; asking +/// the same question of an unattended pod would leave it un-registered forever. +/// +public record InstanceConsent +{ + /// The instance id the consent was given for — the one the registration will claim. + public string InstanceId { get; init; } = ""; + + /// The registry the instance registers at. + public string RegistryUrl { get; init; } = ""; + + /// SHA-256 hex of the privacy statement as shown when it was accepted. + public string PrivacyStatementHash { get; init; } = ""; + + /// SHA-256 hex of the platform terms as shown when they were accepted. + public string TermsHash { get; init; } = ""; + + /// When both were accepted. + public DateTimeOffset AcceptedAt { get; init; } + + /// ObjectId of the global admin who accepted. + public string AcceptedByUserId { get; init; } = ""; + + /// Display name of the accepting admin. + public string AcceptedByName { get; init; } = ""; + + /// Email of the accepting admin. + public string AcceptedByEmail { get; init; } = ""; + + /// Whether this record carries a complete consent — both texts accepted by a named + /// principal. An incomplete record gates exactly like an absent one. + public bool IsComplete => + !string.IsNullOrWhiteSpace(InstanceId) + && !string.IsNullOrWhiteSpace(PrivacyStatementHash) + && !string.IsNullOrWhiteSpace(TermsHash) + && !string.IsNullOrWhiteSpace(AcceptedByUserId) + && AcceptedAt != default; +} diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json index 555cfd6199..41bc3bbff3 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.de.json @@ -989,6 +989,30 @@ "instancePlan.promote": "Plan setzen", "instancePlan.promoted": "Plan gesetzt:", "instancePlan.error.unknownInstance": "Keine registrierte Instanz trägt diese ID.", + "instanceRegistration.title": "Instanzregistrierung", + "instanceRegistration.intro": "Diese Installation registriert sich bei einer Plugin-Registry, um ihre Identität, ein kurzlebiges Token und den Paketkatalog ihres Plans zu erhalten. Das geschieht erst, nachdem ein Plattform-Admin die Datenschutzerklärung und die Plattformbedingungen unten gelesen und akzeptiert hat.", + "instanceRegistration.notConfigured": "Für diese Installation ist keine Registry konfiguriert (PluginCatalog:RegistryUrl / InstanceId). Nichts zu registrieren.", + "instanceRegistration.keyed": "Diese Installation registriert sich mit einem vom Betreiber bereitgestellten Registrierungsschlüssel; ein Zustimmungsschritt entfällt.", + "instanceRegistration.awaitingConsent": "Zustimmung ausstehend — diese Installation ist noch nicht registriert.", + "instanceRegistration.registered": "Registriert", + "instanceRegistration.plan": "Plan", + "instanceRegistration.registry": "Registry", + "instanceRegistration.instanceId": "Instanz-ID", + "instanceRegistration.consentGivenBy": "Zustimmung erteilt von", + "instanceRegistration.packages": "Für diese Installation verfügbare Pakete", + "instanceRegistration.packagesLoading": "Katalog wird gelesen…", + "instanceRegistration.packagesFailed": "Der Katalog konnte nicht gelesen werden:", + "instanceRegistration.privacyHeading": "Datenschutzerklärung", + "instanceRegistration.termsHeading": "Plattformbedingungen", + "instanceRegistration.acceptPrivacy": "Ich habe die Datenschutzerklärung gelesen und akzeptiere sie", + "instanceRegistration.acceptTerms": "Ich habe die Plattformbedingungen gelesen und akzeptiere sie", + "instanceRegistration.register": "Diese Installation registrieren", + "instanceRegistration.registering": "Zustimmung erfasst — Registrierung läuft…", + "instanceRegistration.error.acceptBoth": "Akzeptieren Sie sowohl die Datenschutzerklärung als auch die Plattformbedingungen, um zu registrieren.", + "instanceRegistration.error.failed": "Die Zustimmung konnte nicht erfasst werden:", + "instanceRegistration.withdraw": "Zustimmung zurückziehen", + "instanceRegistration.withdrawn": "Zustimmung zurückgezogen — die Installation registriert sich nicht mehr; löschen Sie die gespeicherten Zugangsdaten, um die Authentifizierung zu beenden.", + "instanceRegistration.missingPrivacy": "Auf dieser Installation ist noch keine Datenschutzerklärung veröffentlicht — veröffentlichen Sie zuerst eine unter Einstellungen ▸ Datenschutz. Sie wird öffentlich unter /privacy bereitgestellt.", "menu.userProfile": "Benutzerprofil", "chat.chat": "Chat", "chat.recentThreads": "Letzte Threads", diff --git a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json index 99cd6ed6e1..0c305f3285 100644 --- a/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json +++ b/src/MeshWeaver.Messaging.Hub/Localization/strings.en.json @@ -989,6 +989,30 @@ "instancePlan.promote": "Set plan", "instancePlan.promoted": "Plan set:", "instancePlan.error.unknownInstance": "No registered instance carries that ID.", + "instanceRegistration.title": "Instance registration", + "instanceRegistration.intro": "This installation registers itself at a plugin registry to obtain its identity, a short-lived token and the package catalogue its plan covers. It does so only after a platform admin has read and accepted the privacy statement and the platform terms below.", + "instanceRegistration.notConfigured": "No registry is configured for this installation (PluginCatalog:RegistryUrl / InstanceId). Nothing to register.", + "instanceRegistration.keyed": "This installation registers with a registration key its operator provisioned; no consent step applies here.", + "instanceRegistration.awaitingConsent": "Awaiting consent — this installation has not registered yet.", + "instanceRegistration.registered": "Registered", + "instanceRegistration.plan": "Plan", + "instanceRegistration.registry": "Registry", + "instanceRegistration.instanceId": "Instance ID", + "instanceRegistration.consentGivenBy": "Consent given by", + "instanceRegistration.packages": "Packages available to this installation", + "instanceRegistration.packagesLoading": "Reading the catalogue…", + "instanceRegistration.packagesFailed": "Could not read the catalogue:", + "instanceRegistration.privacyHeading": "Privacy statement", + "instanceRegistration.termsHeading": "Platform terms", + "instanceRegistration.acceptPrivacy": "I have read and accept the privacy statement", + "instanceRegistration.acceptTerms": "I have read and accept the platform terms", + "instanceRegistration.register": "Register this installation", + "instanceRegistration.registering": "Consent recorded — registering…", + "instanceRegistration.error.acceptBoth": "Accept both the privacy statement and the platform terms to register.", + "instanceRegistration.error.failed": "Could not record the consent:", + "instanceRegistration.withdraw": "Withdraw consent", + "instanceRegistration.withdrawn": "Consent withdrawn — the installation stops registering; delete the stored credential to stop authenticating.", + "instanceRegistration.missingPrivacy": "No privacy statement has been published on this installation yet — publish one under Settings ▸ Privacy before registering. It is served publicly at /privacy.", "menu.userProfile": "User profile", "chat.chat": "Chat", "chat.recentThreads": "Recent threads", diff --git a/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs b/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs index 55246d8c69..c3f9a6e6b1 100644 --- a/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs +++ b/src/MeshWeaver.PluginCatalog/InstanceAutoRegistrationService.cs @@ -7,6 +7,8 @@ using System.Reactive.Linq; using System.Reactive.Subjects; using System.Text.Json; +using MeshWeaver.Data; +using MeshWeaver.Graph; using MeshWeaver.Graph.Configuration; using MeshWeaver.Hosting; using MeshWeaver.Mesh; @@ -43,6 +45,11 @@ public record PluginRegistryCredential /// When the registration happened. public DateTimeOffset RegisteredAt { get; init; } + + /// The plan the registry put this instance on (#2804), as echoed by the registration + /// response — what this installation is entitled to pull, shown in Settings. Null when the + /// registry predates plans; the registry is the authority either way. + public string? Plan { get; init; } } /// Where auto-registration credentials live and how a registry URL maps to a node id. @@ -445,8 +452,14 @@ private void Start() /// An explicitly configured registry token, if any. /// The configured instance id, if any. /// Whether the platform key protector can actually encrypt. + /// Whether a platform admin of this installation has accepted the + /// privacy statement and the platform terms (). Gates the OPEN + /// lane only: an instance provisioned with a registration key was set up by an operator who + /// accepted on the fleet's side, and asking an unattended pod would leave it unregistered + /// forever. internal static (RegistrationPreflight Action, string? Reason) DecideRegistration( - string? bootstrapKey, string? registryToken, string? instanceId, bool masterKeyPresent) + string? bootstrapKey, string? registryToken, string? instanceId, bool masterKeyPresent, + bool consentGiven) { // OPEN registration: an instance id and no key. The registry decides whether it accepts // un-keyed callers and which plan they enrol into (the free tier on the cloud registry); @@ -480,6 +493,16 @@ internal static (RegistrationPreflight Action, string? Reason) DecideRegistratio + "anything is consumed: the bootstrap key stays valid and the instance id stays " + "free, so configuring the master key and restarting completes the registration. " + "(Registering first and failing to store would burn both — #2585.)"); + // The instance starts, and ASKS. An open registration is a deployment presenting itself to + // a registry on someone's behalf; until a platform admin here has read and accepted the + // privacy statement and the platform terms, nothing is registered, no token is obtained + // and no catalogue is pulled. The caller waits for the consent record and then registers — + // no restart, no timer. + if (open && !consentGiven) + return (RegistrationPreflight.AwaitConsent, + "PluginCatalog:InstanceId is set (open registration) but no platform admin has accepted " + + "the privacy statement and platform terms yet — waiting. Open Settings ▸ Instance " + + "registration, review both, and register; the registration then runs at once."); return (RegistrationPreflight.Register, null); } @@ -494,6 +517,10 @@ internal enum RegistrationPreflight /// Present the bootstrap key. Register = 2, + + /// Open registration, and the deployment's consent has not been given yet — wait + /// for the record, then register. + AwaitConsent = 3, } /// @@ -509,18 +536,80 @@ private IObservable EnsureRegistered(PluginCatalogOptions options, PluginR // (IProviderKeyProtector's own doc names this as the sanctioned order). var masterKeyPresent = hub.ServiceProvider.GetService()?.GetMasterKey() is not null; - var (action, reason) = DecideRegistration( - bootstrapKey, registry.Token, options.InstanceId, masterKeyPresent); - switch (action) + + // Consent is read only where it can gate: the OPEN lane with an instance id. A keyed or + // unconfigured deployment never touches the consent record. + var open = bootstrapKey.Length == 0 && !string.IsNullOrWhiteSpace(options.InstanceId); + var consent = open ? ConsentGiven(wait: false) : Observable.Return(true); + + return consent.SelectMany(consentGiven => { - case RegistrationPreflight.Skip: - if (reason is not null) + var (action, reason) = DecideRegistration( + bootstrapKey, registry.Token, options.InstanceId, masterKeyPresent, consentGiven); + switch (action) + { + case RegistrationPreflight.Skip: + if (reason is not null) + logger.LogInformation("{Reason}", reason); + return Observable.Return(Unit.Default); + case RegistrationPreflight.Abort: + logger.LogError("{Reason}", reason); + return Observable.Empty(); // misconfigured → do not go on to install + case RegistrationPreflight.AwaitConsent: logger.LogInformation("{Reason}", reason); - return Observable.Return(Unit.Default); - case RegistrationPreflight.Abort: - logger.LogError("{Reason}", reason); - return Observable.Empty(); // misconfigured → do not go on to install - } + // The instance has asked; now it waits for the answer — the consent record a + // platform admin writes from Settings — and registers the moment it lands. + // Ordering on the actual precondition: no timer, no restart, no poll. + return ConsentGiven(wait: true) + .Where(given => given) + .Take(1) + .Do(_ => logger.LogInformation( + "Consent recorded — registering this installation as '{InstanceId}' at {Url}.", + options.InstanceId!.Trim(), registry.Url)) + .SelectMany(_ => Register(options, registry, bootstrapKey)); + } + return Register(options, registry, bootstrapKey); + }); + } + + /// + /// Whether the deployment's is on record and complete. Existence + /// through a query (empty-on-absent, storm-safe — a point read of a maybe-absent node opens the + /// breaker on the path, #2229), the content off the live node stream. With + /// the query is LEFT OPEN until a record appears, which is how the + /// registration that was waiting for consent wakes up without a restart. + /// + private IObservable ConsentGiven(bool wait) + { + var workspace = hub.GetWorkspace(); + var accessService = hub.ServiceProvider.GetRequiredService(); + var path = MeshWeaverInstanceNodeType.ConsentPath; + var rows = accessService.RunAsSystem(() => workspace.GetQuery( + $"consent|{path}", $"path:{path} nodeType:{MeshWeaverInstanceNodeType.ConsentNodeType}")); + var present = wait + ? rows.Where(r => r.Any()).Take(1) + : rows.Take(1).Timeout(TimeSpan.FromSeconds(10)); + return present + .SelectMany(r => r.Any() + ? accessService.RunAsSystem(() => workspace.GetMeshNodeStream(path) + .Where(node => node is not null) + .Take(1) + .Timeout(TimeSpan.FromSeconds(10))) + .Select(node => node!.ContentAs(hub.JsonSerializerOptions)?.IsComplete ?? false) + : Observable.Return(false)) + .Catch((Exception ex) => + { + // A read that reached no verdict is not consent. Say so, and stay unregistered. + logger.LogWarning(ex, "Could not read the instance consent record at {Path} — treating as not given", path); + return Observable.Return(false); + }); + } + + /// The registration proper — present the key (or none, openly), store the issued + /// credential. Split out of so the consent-deferred path and the + /// immediate path run the same code. + private IObservable Register(PluginCatalogOptions options, PluginRegistryReference registry, string bootstrapKey) + { var instanceId = options.InstanceId!.Trim(); var accessService = hub.ServiceProvider.GetRequiredService(); @@ -566,6 +655,7 @@ private IObservable EnsureRegistered(PluginCatalogOptions options, PluginR // never issues a null key — it either refuses or returns ciphertext. ProtectedKey = protector.Protect(result.InstanceKey)!, RegisteredAt = DateTimeOffset.UtcNow, + Plan = result.Plan, }, }; return Observable.Defer(() => diff --git a/src/MeshWeaver.PluginCatalog/InstanceConsentService.cs b/src/MeshWeaver.PluginCatalog/InstanceConsentService.cs new file mode 100644 index 0000000000..6585023b45 --- /dev/null +++ b/src/MeshWeaver.PluginCatalog/InstanceConsentService.cs @@ -0,0 +1,285 @@ +using System.Reactive; +using System.Reactive.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using MeshWeaver.Data; +using MeshWeaver.Graph; +using MeshWeaver.Graph.Configuration; +using MeshWeaver.Mesh; +using MeshWeaver.Mesh.Security; +using MeshWeaver.Mesh.Services; +using MeshWeaver.Messaging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace MeshWeaver.PluginCatalog; + +/// +/// The platform half of "an installation asks before it registers": the texts a platform admin +/// must accept (the privacy statement at , the platform terms at +/// ), the record that gates the open +/// registration, and the live views an APP needs to show where the installation stands — +/// consent, credential (with the plan the registry put it on), and the catalogue the registry +/// serves it. The app itself is the Hosting plugin's; nothing here renders. +/// +/// Texts are read as System (the Admin partition is not readable by every principal) and +/// hashed for the record exactly as shown, so the audit trail names the version that was +/// accepted. The consent WRITE runs under the caller's own identity: only a global admin can +/// write the Admin partition, and that is precisely what "consent on the deployment's behalf" +/// means — nothing here impersonates for it. +/// +public sealed class InstanceConsentService(IMessageHub hub, ILogger logger) +{ + /// The privacy statement's node (a Markdown node the portal serves at /privacy). + public const string PrivacyPath = "Admin/Privacy"; + + /// The platform terms' node — a Markdown node created here with + /// when absent, editable like any Markdown node. + public const string TermsPath = "Admin/Terms"; + + private const string TermsId = "Terms"; + private const string AdminPartition = "Admin"; + + private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(10); + + /// The default platform terms a fresh installation shows. Generic on purpose: they + /// describe what registering at a registry means for the deployment and leave commercial terms + /// to the plan the registry puts the instance on. + public const string DefaultTerms = """ + # Platform Terms + + By registering this installation at a MeshWeaver plugin registry you agree to the + following on behalf of the organisation operating it. + + ## What registration is + + Registration gives this installation an identity at the registry — an instance id, a + credential, and a plan. The plan determines which packages the registry serves to it. A + new installation starts on the free plan; the registry's platform administrators may + change the plan. + + ## What is exchanged + + The installation sends its instance id, display name and public URL to the registry, and + thereafter presents a short-lived signed token on every request. The registry records when + the installation last authenticated and which packages it pulled. No user data of this + installation is sent to the registry. + + ## Packages + + Packages served by the registry are governed by the licence each package declares; the + Store shows it before a package is installed, and a package that requires acceptance is + not installed until a user accepts. Packages above the installation's plan are not served. + + ## Withdrawal + + A platform administrator of this installation may withdraw this consent in the Hosting + app; the installation then stops registering, and deleting the stored credential stops it + authenticating at the registry. + """; + + /// The registry this installation registers at and the id it claims, from + /// configuration — null when no registry or no instance id is configured. + public (PluginRegistryReference Registry, string InstanceId, bool Keyed)? Target() + { + var options = hub.ServiceProvider.GetService() ?? new PluginCatalogOptions(); + var registry = RegistryTokenResolver.WithLegacyTokens(options, options.EffectiveRegistries).FirstOrDefault(); + var instanceId = options.InstanceId?.Trim() ?? ""; + if (registry is null || instanceId.Length == 0) + return null; + return (registry, instanceId, !string.IsNullOrWhiteSpace(options.BootstrapKey)); + } + + /// Both texts, as shown — the privacy statement (null when none has been PUBLISHED on + /// this installation: there is nothing to accept, and the app says so in the viewer's language) + /// and the terms (created with on first use). Emits once. + public IObservable Texts() + { + var access = hub.ServiceProvider.GetService(); + var workspace = hub.GetWorkspace(); + var meshService = hub.ServiceProvider.GetRequiredService(); + + IObservable Read(string path, string? fallback) => workspace + .GetQuery($"consent-text|{path}", $"path:{path} nodeType:Markdown") + .Take(1) + .Timeout(ReadTimeout) + .SelectMany(nodes => nodes.Any() + ? workspace.GetMeshNodeStream(path) + .Where(node => node is not null) + .Take(1) + .Timeout(ReadTimeout) + .Select(node => Markdown(node!.Content) ?? fallback) + : Observable.Return(fallback)); + + IObservable EnsureTerms() => workspace + .GetQuery($"consent-text|{TermsPath}", $"path:{TermsPath} nodeType:Markdown") + .Take(1) + .Timeout(ReadTimeout) + .SelectMany(nodes => nodes.Any() + ? Observable.Return(TermsPath) + : meshService.CreateNode(new MeshNode(TermsId, AdminPartition) + { + NodeType = "Markdown", + Name = "Platform Terms", + State = MeshNodeState.Active, + Content = new { content = DefaultTerms }, + }) + .Select(_ => TermsPath) + .Catch(ex => IsAlreadyExists(ex) + ? Observable.Return(TermsPath) + : Observable.Throw(ex))); + + // One sealed System scope around the reads (#1790): the subscriber is the admin's circuit. + return access.RunAsSystem(() => EnsureTerms() + .SelectMany(_ => Observable.CombineLatest( + Read(PrivacyPath, null), + Read(TermsPath, DefaultTerms), + (privacy, terms) => new ConsentTexts(privacy, terms ?? DefaultTerms)) + .Take(1))); + } + + /// The consent record, or null — LIVE: the app's form gives way the moment it is + /// written and returns the moment it is withdrawn. Existence through a query (a point read of + /// a maybe-absent node opens the storm breaker, #2229), content off the node stream. + public IObservable Observe() + { + var workspace = hub.GetWorkspace(); + var path = MeshWeaverInstanceNodeType.ConsentPath; + return workspace + .GetQuery($"consent|{path}", $"path:{path} nodeType:{MeshWeaverInstanceNodeType.ConsentNodeType}") + .Select(rows => rows.Any()) + .DistinctUntilChanged() + .Select(present => present + ? workspace.GetMeshNodeStream(path) + .Where(node => node is not null) + .Select(node => node!.ContentAs(hub.JsonSerializerOptions)) + : Observable.Return(null)) + .Switch(); + } + + /// The stored registry credential for , or null — LIVE, + /// so a view flips to "registered" when the waiting registration completes after consent. + /// Carries the plan the registry echoed. + public IObservable Credential(string registryUrl) + { + var workspace = hub.GetWorkspace(); + var path = PluginRegistryCredentials.Path(registryUrl); + return workspace + .GetQuery($"credential|{path}", $"path:{path} nodeType:{PluginRegistryCredentials.NodeType}") + .Select(rows => rows.Any()) + .DistinctUntilChanged() + .Select(present => present + ? workspace.GetMeshNodeStream(path) + .Where(node => node is not null) + .Select(node => node!.ContentAs(hub.JsonSerializerOptions)) + : Observable.Return(null)) + .Switch(); + } + + /// + /// Records the consent: the two texts as shown (hashed), the instance and registry, the + /// accepting principal from the CALLER's context. Create, never create-or-update — a second + /// consent does not overwrite the first. Runs under the caller's identity: writing the Admin + /// partition is what only a global admin may do, which is the whole meaning of consenting on + /// the deployment's behalf. Cold — subscribe to write. + /// + public IObservable Give(ConsentTexts texts, string instanceId, string registryUrl) + { + if (string.IsNullOrWhiteSpace(instanceId)) + return Observable.Throw(new ArgumentException("An instance id is required.", nameof(instanceId))); + if (!texts.CanBeAccepted) + return Observable.Throw(new InvalidOperationException( + "No privacy statement has been published on this installation — there is nothing to accept. " + + "Publish one under Settings ▸ Privacy first.")); + var context = hub.ServiceProvider.GetService()?.Context; + if (context is null || string.IsNullOrWhiteSpace(context.ObjectId)) + return Observable.Throw(new InvalidOperationException( + "Consent must be given by a signed-in platform admin — no principal is on the current context.")); + + var consent = new InstanceConsent + { + InstanceId = instanceId.Trim(), + RegistryUrl = (registryUrl ?? "").TrimEnd('/'), + PrivacyStatementHash = Sha256(texts.Privacy!), + TermsHash = Sha256(texts.Terms), + AcceptedAt = DateTimeOffset.UtcNow, + AcceptedByUserId = context.ObjectId, + AcceptedByName = context.Name ?? "", + AcceptedByEmail = context.Email ?? "", + }; + var node = new MeshNode(MeshWeaverInstanceNodeType.ConsentId, MeshWeaverInstanceNodeType.ConsentNamespace) + { + Name = "Instance consent", + NodeType = MeshWeaverInstanceNodeType.ConsentNodeType, + State = MeshNodeState.Active, + Content = consent, + }; + return hub.ServiceProvider.GetRequiredService() + .CreateNode(node) + .Do(_ => logger.LogInformation( + "Instance consent recorded for '{InstanceId}' at {Registry} by {User}", + consent.InstanceId, consent.RegistryUrl, consent.AcceptedByEmail)); + } + + /// Withdraws the consent by deleting the record — the installation stops registering. + /// The stored credential is left in place; deleting it is the separate, louder step that stops + /// an already-registered installation from authenticating. Cold. + public IObservable Withdraw() => + hub.ServiceProvider.GetRequiredService() + .DeleteNode(MeshWeaverInstanceNodeType.ConsentPath) + .Select(_ => Unit.Default) + .Do(_ => logger.LogInformation("Instance consent withdrawn")); + + /// + /// The catalogue the registry serves THIS installation, listed through the same token exchange + /// every catalogue call uses — so the count an app shows is the count the Store sees: the + /// packages the instance's plan covers, from the sources it is granted. Emits once. + /// + public IObservable> Catalogue(PluginRegistryReference registry) + { + var resolver = hub.ServiceProvider.GetRequiredService(); + return resolver.ResolveToken(registry) + .SelectMany(token => new RegistryPackageSource(hub, registry.Url, token).ListPackages("HEAD")) + .Take(1); + } + + private string? Markdown(object? content) => content switch + { + string s => s, + JsonElement je when je.ValueKind == JsonValueKind.Object + && je.TryGetProperty("content", out var c) && c.ValueKind == JsonValueKind.String + => c.GetString(), + JsonElement je when je.ValueKind == JsonValueKind.String => je.GetString(), + null => null, + _ => JsonSerializer.SerializeToElement(content, hub.JsonSerializerOptions) is var element + && element.ValueKind == JsonValueKind.Object + && element.TryGetProperty("content", out var typed) && typed.ValueKind == JsonValueKind.String + ? typed.GetString() + : null, + }; + + private static bool IsAlreadyExists(Exception ex) + { + for (var e = ex; e is not null; e = e.InnerException) + if (e.Message?.Contains("already exists", StringComparison.OrdinalIgnoreCase) == true) + return true; + return false; + } + + private static string Sha256(string text) => + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(text ?? ""))).ToLowerInvariant(); +} + +/// The two texts a consent is given for, as shown. +/// The privacy statement markdown — null when none has been published on this +/// installation, in which case there is nothing to accept yet. +/// The platform terms markdown. +public sealed record ConsentTexts(string? Privacy, string Terms) +{ + /// Whether a consent can be given on these texts: a published privacy statement and + /// non-empty terms. The seeded terms are an OPERATOR document, shipped in English like the + /// privacy statement's own default and edited under the node; the app's chrome is localized, + /// the documents are whatever the operator publishes. + public bool CanBeAccepted => !string.IsNullOrWhiteSpace(Privacy) && !string.IsNullOrWhiteSpace(Terms); +} diff --git a/src/MeshWeaver.PluginCatalog/InstanceRegistrationClient.cs b/src/MeshWeaver.PluginCatalog/InstanceRegistrationClient.cs index 02f7270809..17c22daac3 100644 --- a/src/MeshWeaver.PluginCatalog/InstanceRegistrationClient.cs +++ b/src/MeshWeaver.PluginCatalog/InstanceRegistrationClient.cs @@ -30,7 +30,14 @@ public record Request( /// The success response: the registered id, and the instance key — the ONLY time it is /// available in the clear. The caller must persist it now or lose it. - public record Response(string InstanceId, string InstanceKey); + public record Response(string InstanceId, string InstanceKey) + { + /// The plan the instance was registered ON (#2804) — the licence every fetch is + /// decided against, echoed so the installation can say what it is entitled to without a + /// second call. Null from a registry that predates plans. An init property, not a + /// constructor parameter, so the wire shape stays binary-compatible. + public string? Plan { get; init; } + } } /// diff --git a/src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs b/src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs index 6b6faf25fc..5060474945 100644 --- a/src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs +++ b/src/MeshWeaver.PluginCatalog/PluginCatalogConfigurationExtensions.cs @@ -84,6 +84,9 @@ public static TBuilder AddPluginCatalog(this TBuilder builder) where T // The plan on the instance record — promoted by a global admin, read by every // registry decision (#2804). .AddSingleton() + // The consent that gates an open registration, and the live views the Hosting + // app renders it from (#2804 program, slice 3). + .AddSingleton() // Registration bootstrap keys (mwr_) — minted on the admin surface, validated by // the /api/instances/register endpoint. Mesh-scoped like everything above. .AddSingleton() diff --git a/test/MeshWeaver.Auth.Test/RegistrationPreflightTest.cs b/test/MeshWeaver.Auth.Test/RegistrationPreflightTest.cs index 9680289f20..017b1782a3 100644 --- a/test/MeshWeaver.Auth.Test/RegistrationPreflightTest.cs +++ b/test/MeshWeaver.Auth.Test/RegistrationPreflightTest.cs @@ -19,7 +19,7 @@ public class RegistrationPreflightTest public void NoBootstrapKey_IsANoOp() { var (action, reason) = InstanceAutoRegistrationService.DecideRegistration( - null, null, null, masterKeyPresent: false); + null, null, null, masterKeyPresent: false, consentGiven: true); Assert.Equal(InstanceAutoRegistrationService.RegistrationPreflight.Skip, action); Assert.Null(reason); } @@ -28,7 +28,7 @@ public void NoBootstrapKey_IsANoOp() public void ExplicitToken_WinsOverTheBootstrapKey() { var (action, reason) = InstanceAutoRegistrationService.DecideRegistration( - Key, "mwi_configured", "my-instance", masterKeyPresent: true); + Key, "mwi_configured", "my-instance", masterKeyPresent: true, consentGiven: true); Assert.Equal(InstanceAutoRegistrationService.RegistrationPreflight.Skip, action); Assert.Contains("explicit token wins", reason); } @@ -37,7 +37,7 @@ public void ExplicitToken_WinsOverTheBootstrapKey() public void MissingInstanceId_Aborts_NamingTheSetting() { var (action, reason) = InstanceAutoRegistrationService.DecideRegistration( - Key, null, " ", masterKeyPresent: true); + Key, null, " ", masterKeyPresent: true, consentGiven: true); Assert.Equal(InstanceAutoRegistrationService.RegistrationPreflight.Abort, action); Assert.Contains("PluginCatalog:InstanceId", reason); } @@ -50,7 +50,7 @@ public void NoMasterKey_Aborts_BeforeAnythingIsConsumed() // was consumed, because the whole point of refusing early is that the bootstrap key stays // valid and the instance id stays free. var (action, reason) = InstanceAutoRegistrationService.DecideRegistration( - Key, null, "my-instance", masterKeyPresent: false); + Key, null, "my-instance", masterKeyPresent: false, consentGiven: true); Assert.Equal(InstanceAutoRegistrationService.RegistrationPreflight.Abort, action); Assert.Contains("Ai:KeyProtection:MasterKey", reason); Assert.Contains("before anything is consumed", reason); @@ -60,7 +60,7 @@ public void NoMasterKey_Aborts_BeforeAnythingIsConsumed() public void FullyConfigured_Registers() { var (action, reason) = InstanceAutoRegistrationService.DecideRegistration( - Key, "", "my-instance", masterKeyPresent: true); + Key, "", "my-instance", masterKeyPresent: true, consentGiven: true); Assert.Equal(InstanceAutoRegistrationService.RegistrationPreflight.Register, action); Assert.Null(reason); } diff --git a/test/MeshWeaver.PluginCatalog.Test/RegistrationPreflightTest.cs b/test/MeshWeaver.PluginCatalog.Test/RegistrationPreflightTest.cs index 709b04402f..82e09ff193 100644 --- a/test/MeshWeaver.PluginCatalog.Test/RegistrationPreflightTest.cs +++ b/test/MeshWeaver.PluginCatalog.Test/RegistrationPreflightTest.cs @@ -20,7 +20,7 @@ public class RegistrationPreflightTest [Fact] public void NothingConfigured_SkipsSilently() { - var (action, reason) = DecideRegistration(null, null, null, masterKeyPresent: true); + var (action, reason) = DecideRegistration(null, null, null, masterKeyPresent: true, consentGiven: true); Assert.Equal(RegistrationPreflight.Skip, action); Assert.Null(reason); } @@ -30,7 +30,7 @@ public void AnInstanceIdWithoutAKey_RegistersOpenly() // The Homebrew default: `memex-local registry https://memex.meshweaver.cloud` with no key. // Whether the registry accepts it — and into which plan — is the REGISTRY's decision. => Assert.Equal(RegistrationPreflight.Register, - DecideRegistration("", "", "roland-macbook", masterKeyPresent: true).Action); + DecideRegistration("", "", "roland-macbook", masterKeyPresent: true, consentGiven: true).Action); [Fact] public void OpenRegistration_WithoutAMasterKey_SkipsInsteadOfAborting() @@ -38,7 +38,7 @@ public void OpenRegistration_WithoutAMasterKey_SkipsInsteadOfAborting() // The issued instance key is stored encrypted or not at all (#2585). A keyed deployment // that cannot encrypt ABORTS (the key would be burned); an open one has nothing to burn, so // it skips — but says why, because a silent skip is a registration nobody can explain. - var (action, reason) = DecideRegistration("", "", "roland-macbook", masterKeyPresent: false); + var (action, reason) = DecideRegistration("", "", "roland-macbook", masterKeyPresent: false, consentGiven: true); Assert.Equal(RegistrationPreflight.Skip, action); Assert.Contains("MasterKey", reason); } @@ -47,24 +47,49 @@ public void OpenRegistration_WithoutAMasterKey_SkipsInsteadOfAborting() public void AnExplicitToken_WinsOverBothLanes() { Assert.Equal(RegistrationPreflight.Skip, - DecideRegistration("mwr_key", "mwi_token", "id", masterKeyPresent: true).Action); + DecideRegistration("mwr_key", "mwi_token", "id", masterKeyPresent: true, consentGiven: true).Action); Assert.Equal(RegistrationPreflight.Skip, - DecideRegistration("", "mwi_token", "id", masterKeyPresent: true).Action); + DecideRegistration("", "mwi_token", "id", masterKeyPresent: true, consentGiven: true).Action); } [Fact] public void AKeyWithoutAnInstanceId_Aborts() => Assert.Equal(RegistrationPreflight.Abort, - DecideRegistration("mwr_key", "", "", masterKeyPresent: true).Action); + DecideRegistration("mwr_key", "", "", masterKeyPresent: true, consentGiven: true).Action); [Fact] public void AKeyWithoutAMasterKey_Aborts() // #2585 — registering first and failing to store would burn the key AND the id. => Assert.Equal(RegistrationPreflight.Abort, - DecideRegistration("mwr_key", "", "id", masterKeyPresent: false).Action); + DecideRegistration("mwr_key", "", "id", masterKeyPresent: false, consentGiven: true).Action); + + [Fact] + public void OpenRegistration_WithoutConsent_WaitsInsteadOfRegistering() + { + // The instance starts, and ASKS. Until a platform admin accepts the privacy statement and + // the platform terms, an open registration neither registers nor aborts — it waits, and + // says where the answer is given. + var (action, reason) = DecideRegistration("", "", "roland-macbook", masterKeyPresent: true, consentGiven: false); + Assert.Equal(RegistrationPreflight.AwaitConsent, action); + Assert.Contains("Instance registration", reason); + } + + [Fact] + public void AKeyedRegistration_IsNotGatedOnConsent() + // An operator who provisions with a registration key accepted the terms on the fleet's + // side; an unattended pod asked the same question would stay unregistered forever. + => Assert.Equal(RegistrationPreflight.Register, + DecideRegistration("mwr_key", "", "id", masterKeyPresent: true, consentGiven: false).Action); + + [Fact] + public void OpenRegistration_TheMasterKeyIsCheckedBeforeConsent() + // Infrastructure before people: an admin who consents on an instance that cannot store the + // key would be told AFTER consenting — so the config problem is reported first. + => Assert.Equal(RegistrationPreflight.Skip, + DecideRegistration("", "", "id", masterKeyPresent: false, consentGiven: false).Action); [Fact] public void AKeyAndAnInstanceId_Register() => Assert.Equal(RegistrationPreflight.Register, - DecideRegistration("mwr_key", "", "id", masterKeyPresent: true).Action); + DecideRegistration("mwr_key", "", "id", masterKeyPresent: true, consentGiven: true).Action); }