From 874d51199f5b6f8795253f34cedd922053740371 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Mon, 24 Aug 2026 21:23:58 -0700 Subject: [PATCH] feat(apps): reconcile managed apps from the fleet desired state Nodes converge their installed apps toward a desired-set document the authority writes into the fleet state mirror. The document arrives as an ordinary signed, revision-fenced state mutation, so managed app install adds no new command kind, endpoint, or inbound channel. The two-root design is the grant boundary. Grants exist only inside a signed bundle, never in the catalogue, so a node installs an unreviewed app into a staging root the supervisor does not scan: the binary is present, its manifest can be read and reported, and it cannot run. Promotion into the live install root happens only once the desired document accepts every grant the manifest declares -- and a catalogue republish that widens them demotes the app rather than silently keeping the wider capability set. Installation itself shells out to the pilotctl beside the daemon. pilotctl owns the only implementation of the catalogue trust chain, and a second verifier for a security boundary is the one thing that must not exist; AppInstaller keeps that swappable if the path is later extracted. The inventory is republished only when it actually changes: it lives in the state mirror, so a ticking timestamp would churn the revision the console fences its mutations on and invalidate in-flight installs. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/daemon/main.go | 51 ++ internal/enterprisecontrol/control.go | 43 ++ internal/enterprisecontrol/fleet_apps.go | 429 ++++++++++++++++ internal/enterprisecontrol/fleet_apps_test.go | 471 ++++++++++++++++++ internal/managedsdk/authority/fleet_apps.go | 264 ++++++++++ 5 files changed, 1258 insertions(+) create mode 100644 internal/enterprisecontrol/fleet_apps.go create mode 100644 internal/enterprisecontrol/fleet_apps_test.go create mode 100644 internal/managedsdk/authority/fleet_apps.go diff --git a/cmd/daemon/main.go b/cmd/daemon/main.go index bb8c7280..efff87a9 100644 --- a/cmd/daemon/main.go +++ b/cmd/daemon/main.go @@ -11,6 +11,7 @@ import ( "log" "log/slog" "os" + "os/exec" "os/signal" "path/filepath" "strconv" @@ -568,6 +569,23 @@ func main() { }() } + if enterpriseControls.HasAppReconcile() { + appInstaller := enterprisecontrol.PilotctlInstaller{BinaryPath: pilotctlBinaryPath()} + reconcileApps(fleetControlCtx, enterpriseControls, appInstaller) + go func() { + ticker := time.NewTicker(enterpriseControls.AppReconcileInterval()) + defer ticker.Stop() + for { + select { + case <-fleetControlCtx.Done(): + return + case <-ticker.C: + reconcileApps(fleetControlCtx, enterpriseControls, appInstaller) + } + } + }() + } + receiptExportCtx, receiptExportCancel := context.WithCancel(context.Background()) if enterpriseControls.HasReceiptExport() { if err := enterpriseControls.ExportReceiptsOnce(receiptExportCtx); err != nil { @@ -769,6 +787,39 @@ func synchronizeFleetControl(ctx context.Context, controls *enterprisecontrol.Ru } } +// pilotctlBinaryPath resolves the pilotctl that ships beside this daemon. +// Preferring the sibling binary over $PATH keeps the verified install path +// pinned to the same release as the daemon rather than to whatever a user +// happens to have earlier in their environment. +func pilotctlBinaryPath() string { + if executable, err := os.Executable(); err == nil { + sibling := filepath.Join(filepath.Dir(executable), "pilotctl") + if info, statErr := os.Stat(sibling); statErr == nil && !info.IsDir() { + return sibling + } + } + if resolved, err := exec.LookPath("pilotctl"); err == nil { + return resolved + } + return "pilotctl" +} + +// reconcileApps converges installed apps toward the authority's desired set. +// A failure here must never disturb policy enforcement or the state mirror, so +// it is logged and retried on the next tick rather than propagated. +func reconcileApps(ctx context.Context, controls *enterprisecontrol.Runtime, installer enterprisecontrol.AppInstaller) { + result, err := controls.ReconcileApps(ctx, installer) + if err != nil { + slog.Warn("managed app reconcile failed", "err", err) + return + } + if result.Installed+result.Staged+result.Removed+result.Failed > 0 { + slog.Info("managed apps reconciled", + "desired", result.Desired, "installed", result.Installed, + "awaiting_grants", result.Staged, "removed", result.Removed, "failed", result.Failed) + } +} + func synchronizeFleetState(ctx context.Context, controls *enterprisecontrol.Runtime) { result, err := controls.SyncFleetState(ctx) if err != nil { diff --git a/internal/enterprisecontrol/control.go b/internal/enterprisecontrol/control.go index 04b833cf..1f2cc252 100644 --- a/internal/enterprisecontrol/control.go +++ b/internal/enterprisecontrol/control.go @@ -48,6 +48,7 @@ type Config struct { Receipts *ReceiptConfig `json:"receipts,omitempty"` Rollout *RolloutConfig `json:"rollout,omitempty"` Fleet *FleetConfig `json:"fleet,omitempty"` + Apps *AppsConfig `json:"apps,omitempty"` OutboundDecisions *OutboundDecisionConfig `json:"outbound_decisions,omitempty"` ActionControl *ActionControlConfig `json:"action_control,omitempty"` ContentInspection *ContentInspectionConfig `json:"content_inspection,omitempty"` @@ -277,6 +278,12 @@ type Runtime struct { fleetStateRevision uint64 fleetStateRootHash string fleetStatePendingResults []authority.FleetStateMutationResult + appsEnabled bool + appsInterval time.Duration + appsInstallRoot string + appsStagingRoot string + appsMu sync.Mutex + appsManaged map[string]struct{} outboundClient *decisionhttp.Client outboundAgentID string outboundKeyID string @@ -636,6 +643,42 @@ func Load(path string) (*Runtime, error) { } } } + if config.Apps != nil && config.Apps.Enabled { + // Apps ride on the state mirror: the desired document arrives as an + // ordinary signed state mutation, so without state sync there is no + // channel to receive one and nothing to reconcile toward. + if !runtime.fleetStateEnabled { + return nil, fmt.Errorf("enterprise control: app reconciliation requires fleet state sync") + } + home, homeErr := os.UserHomeDir() + if homeErr != nil && (config.Apps.InstallRoot == "" || config.Apps.StagingRoot == "") { + return nil, fmt.Errorf("enterprise control: app roots: %w", homeErr) + } + installRoot := strings.TrimSpace(config.Apps.InstallRoot) + if installRoot == "" { + installRoot = filepath.Join(home, ".pilot", "apps") + } + stagingRoot := strings.TrimSpace(config.Apps.StagingRoot) + if stagingRoot == "" { + // Deliberately a sibling of the install root, never inside it: the + // supervisor scans the install root, and an app awaiting grant + // acceptance must be somewhere it will not be spawned from. + stagingRoot = filepath.Join(home, ".pilot", "apps-pending") + } + if installRoot == stagingRoot { + return nil, fmt.Errorf("enterprise control: app staging_root must differ from install_root") + } + if withinDirectory(installRoot, stagingRoot) { + return nil, fmt.Errorf("enterprise control: app staging_root must not sit inside install_root") + } + runtime.appsEnabled = true + runtime.appsInstallRoot, runtime.appsStagingRoot = installRoot, stagingRoot + runtime.appsManaged = make(map[string]struct{}) + runtime.appsInterval = time.Duration(config.Apps.ReconcileIntervalSeconds) * time.Second + if runtime.appsInterval == 0 { + runtime.appsInterval = 30 * time.Second + } + } if config.DataExchange != nil { runtime.dataEnabled = true runtime.dataRequired = config.DataExchange.RequireGoverned diff --git a/internal/enterprisecontrol/fleet_apps.go b/internal/enterprisecontrol/fleet_apps.go new file mode 100644 index 00000000..4b3f66b7 --- /dev/null +++ b/internal/enterprisecontrol/fleet_apps.go @@ -0,0 +1,429 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "time" + + appmanifest "github.com/pilot-protocol/app-store/pkg/manifest" + "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" +) + +// AppsConfig enables managed app reconciliation. It is opt-in for the same +// reason fleet state sync is: an unmanaged node must never acquire software +// because some authority asked it to. +type AppsConfig struct { + Enabled bool `json:"enabled,omitempty"` + InstallRoot string `json:"install_root,omitempty"` + StagingRoot string `json:"staging_root,omitempty"` + InstallerPath string `json:"installer_path,omitempty"` + ReconcileIntervalSeconds int64 `json:"reconcile_interval_seconds,omitempty"` +} + +// AppReconcileResult is bounded operational information for daemon logs. App +// identifiers are catalogue-public, so they may cross this boundary; local +// paths and manifest contents may not. +type AppReconcileResult struct { + Desired int + Installed int + Staged int + Removed int + Failed int +} + +// AppInstaller performs the actual bundle fetch, verification, and extraction. +// +// It is an interface because the verified install path currently lives in +// pilotctl's `package main` and cannot be linked into the daemon. Reconciliation +// logic is therefore testable without a real network or a real pilotctl binary, +// and the concrete implementation can later be swapped for an extracted +// library without touching anything here. +type AppInstaller interface { + // Install places appID at the requested version beneath root, performing + // the same catalogue signature and sha256 checks an operator would get at + // the keyboard. + Install(ctx context.Context, appID, version, root string) error + // Remove deletes appID from root. Removing an app that is not present is + // not an error. + Remove(ctx context.Context, appID, root string) error +} + +// PilotctlInstaller drives the pilotctl binary that ships beside the daemon. +// +// Shelling out is deliberate rather than convenient: pilotctl owns the only +// implementation of the catalogue trust chain (publisher signature, per-platform +// bundle pin, sha256 verification, sideload clamping). Reimplementing that here +// would create a second, subtly different verifier — the one outcome that must +// not happen for a security boundary. +type PilotctlInstaller struct { + BinaryPath string + Timeout time.Duration +} + +func (installer PilotctlInstaller) timeout() time.Duration { + if installer.Timeout > 0 { + return installer.Timeout + } + return 10 * time.Minute +} + +func (installer PilotctlInstaller) Install(ctx context.Context, appID, version, root string) error { + ctx, cancel := context.WithTimeout(ctx, installer.timeout()) + defer cancel() + // --force lets an install replace a wrong-version copy in place; the + // catalogue signature and sha256 gates still run either way. + command := exec.CommandContext(ctx, installer.BinaryPath, "appstore", "install", appID, "--force") + command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("install %s: %w: %s", appID, err, boundedInstallerOutput(output)) + } + return nil +} + +func (installer PilotctlInstaller) Remove(ctx context.Context, appID, root string) error { + target := filepath.Join(root, appID) + if _, err := os.Stat(target); os.IsNotExist(err) { + return nil + } + ctx, cancel := context.WithTimeout(ctx, installer.timeout()) + defer cancel() + command := exec.CommandContext(ctx, installer.BinaryPath, "appstore", "uninstall", appID, "--yes") + command.Env = append(os.Environ(), "PILOT_APPSTORE_ROOT="+root) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("uninstall %s: %w: %s", appID, err, boundedInstallerOutput(output)) + } + return nil +} + +// boundedInstallerOutput keeps a failing subprocess's tail for the daemon log +// without letting an unbounded child write flood it. +func boundedInstallerOutput(output []byte) string { + const limit = 512 + text := strings.TrimSpace(string(output)) + if len(text) > limit { + text = text[len(text)-limit:] + } + return strings.ReplaceAll(text, "\n", " ") +} + +// HasAppReconcile reports whether this node reconciles managed apps. +// +// It requires the state mirror rather than the full fleet control channel: +// the desired document is delivered as a state mutation and then read from +// disk, so reconciliation is correct even during a spell when the authority is +// unreachable. Config load already refuses to enable apps without state sync. +func (runtime *Runtime) HasAppReconcile() bool { + return runtime != nil && runtime.appsEnabled && runtime.fleetStateEnabled && runtime.fleetStateRoot != "" +} + +func (runtime *Runtime) AppReconcileInterval() time.Duration { + if !runtime.HasAppReconcile() { + return 0 + } + return runtime.appsInterval +} + +// ReconcileApps converges the node's installed apps toward the authority's +// desired set and republishes what it observes. +// +// The two-root design is the grant boundary. An app whose declared grants the +// tenant has not accepted is installed into the staging root, which the +// supervisor does not scan — so its binary exists, its manifest can be read and +// reported, and it cannot run. Promotion into the live install root happens +// only once the desired document carries an acceptance covering every grant the +// manifest declares. A catalogue that later widens an app's grants demotes it +// back to staging on the next pass rather than silently gaining capability. +func (runtime *Runtime) ReconcileApps(ctx context.Context, installer AppInstaller) (AppReconcileResult, error) { + if !runtime.HasAppReconcile() { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app reconciliation is not configured") + } + if installer == nil { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app installer is required") + } + runtime.appsMu.Lock() + defer runtime.appsMu.Unlock() + + runtime.mu.Lock() + tenantID, agentID := runtime.tenantID, runtime.rolloutAgentID + stateRoot, installRoot, stagingRoot := runtime.fleetStateRoot, runtime.appsInstallRoot, runtime.appsStagingRoot + runtime.mu.Unlock() + + desired, err := readDesiredApps(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), tenantID, agentID) + if err != nil { + return AppReconcileResult{}, err + } + if err := secureDirectory(installRoot); err != nil { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app install root: %w", err) + } + if err := secureDirectory(stagingRoot); err != nil { + return AppReconcileResult{}, fmt.Errorf("enterprise control: app staging root: %w", err) + } + + result := AppReconcileResult{Desired: len(desired.Desired)} + observed := make([]authority.FleetAppState, 0, len(desired.Desired)) + wanted := make(map[string]struct{}, len(desired.Desired)) + now := time.Now().UTC() + + for _, spec := range desired.Desired { + wanted[spec.ID] = struct{}{} + state := runtime.reconcileOneApp(ctx, installer, spec, installRoot, stagingRoot, now) + switch state.Status { + case authority.FleetAppInstalled: + result.Installed++ + case authority.FleetAppGrantBlocked: + result.Staged++ + case authority.FleetAppFailed: + result.Failed++ + } + observed = append(observed, state) + } + + // Anything this node installed under management but no longer wants is + // withdrawn from both roots. Apps a local operator installed by hand are + // deliberately untouched: management adds and removes what it was asked + // to, and does not assert ownership of the whole install root. + for _, appID := range managedAppIDs(stagingRoot) { + if _, keep := wanted[appID]; keep { + continue + } + if err := installer.Remove(ctx, appID, stagingRoot); err == nil { + result.Removed++ + } + } + for _, appID := range previouslyManaged(runtime.appsManaged, wanted) { + if err := installer.Remove(ctx, appID, installRoot); err == nil { + result.Removed++ + } + } + runtime.appsManaged = wanted + + sort.Slice(observed, func(i, j int) bool { return observed[i].ID < observed[j].ID }) + report := authority.FleetAppsReport{ + Version: authority.FleetAppsVersion, TenantID: tenantID, AgentID: agentID, + Apps: observed, ObservedAt: now.Unix(), + } + if err := writeObservedApps(filepath.Join(stateRoot, authority.FleetAppsReportPath), report); err != nil { + return result, err + } + return result, nil +} + +func (runtime *Runtime) reconcileOneApp(ctx context.Context, installer AppInstaller, spec authority.FleetAppSpec, installRoot, stagingRoot string, now time.Time) authority.FleetAppState { + state := authority.FleetAppState{ID: spec.ID, Version: spec.Version, ObservedAt: now.Unix()} + + live, liveErr := readAppManifest(filepath.Join(installRoot, spec.ID)) + staged, stagedErr := readAppManifest(filepath.Join(stagingRoot, spec.ID)) + + // Already live at the right version with an acceptance that still covers + // what it declares: nothing to do. + if liveErr == nil && live.AppVersion == spec.Version { + declared := manifestGrants(live) + state.DeclaredGrants, state.BinarySHA256 = declared, live.Binary.SHA256 + if authority.GrantsCovered(declared, spec.AcceptedGrants) { + state.Status = authority.FleetAppInstalled + return state + } + // Acceptance no longer covers the manifest. Demote rather than let a + // widened grant set keep running. + if err := installer.Remove(ctx, spec.ID, installRoot); err != nil { + state.Status, state.Detail = authority.FleetAppFailed, "demote_failed" + return state + } + liveErr, live = fmt.Errorf("demoted"), appmanifest.Manifest{} + } + + // Ensure a staged copy at the requested version exists so the manifest — + // the only truthful source of grants — can be read. + if stagedErr != nil || staged.AppVersion != spec.Version { + if err := installer.Install(ctx, spec.ID, spec.Version, stagingRoot); err != nil { + state.Status, state.Detail = authority.FleetAppFailed, "install_failed" + return state + } + staged, stagedErr = readAppManifest(filepath.Join(stagingRoot, spec.ID)) + if stagedErr != nil { + state.Status, state.Detail = authority.FleetAppFailed, "manifest_unreadable" + return state + } + } + + declared := manifestGrants(staged) + state.DeclaredGrants, state.BinarySHA256 = declared, staged.Binary.SHA256 + if staged.AppVersion != "" { + state.Version = staged.AppVersion + } + + if !authority.GrantsCovered(declared, spec.AcceptedGrants) { + // Held deliberately: installed, readable, reported, not running. + state.Status, state.Detail = authority.FleetAppGrantBlocked, "awaiting_grant_acceptance" + return state + } + + // Accepted — promote into the supervisor's scan root. + if err := installer.Install(ctx, spec.ID, spec.Version, installRoot); err != nil { + state.Status, state.Detail = authority.FleetAppFailed, "promote_failed" + return state + } + if err := installer.Remove(ctx, spec.ID, stagingRoot); err != nil { + // A leftover staged copy is inert; it must not fail the reconcile. + state.Detail = "staging_cleanup_deferred" + } + state.Status = authority.FleetAppInstalled + return state +} + +func readDesiredApps(path, tenantID, agentID string) (authority.FleetAppsDocument, error) { + var document authority.FleetAppsDocument + raw, err := os.ReadFile(path) // #nosec G304 -- path is built from the runtime's own confined state root. + if os.IsNotExist(err) { + // No desired set is a valid state meaning "manage no apps here". + return authority.FleetAppsDocument{Version: authority.FleetAppsVersion, TenantID: tenantID, AgentID: agentID}, nil + } + if err != nil { + return document, fmt.Errorf("enterprise control: read desired apps: %w", err) + } + if err := json.Unmarshal(raw, &document); err != nil { + return document, fmt.Errorf("enterprise control: parse desired apps: %w", err) + } + if err := document.Validate(); err != nil { + return authority.FleetAppsDocument{}, err + } + // The document arrives inside a signed, revision-fenced mutation, but it + // then sits on local disk. Re-checking that it still addresses this node + // costs nothing and refuses a file copied from another machine. + if document.TenantID != tenantID || document.AgentID != agentID { + return authority.FleetAppsDocument{}, fmt.Errorf("enterprise control: desired apps document addresses another node") + } + return document, nil +} + +// writeObservedApps republishes the inventory only when something an operator +// would care about actually changed. +// +// This is not an optimization. The report lives inside the fleet state mirror, +// so every rewrite bumps the node's state revision — and the console fences its +// mutations on that revision. Refreshing a timestamp every reconcile tick would +// invalidate an operator's in-flight install before they could confirm it. +func writeObservedApps(path string, report authority.FleetAppsReport) error { + if err := report.Validate(); err != nil { + return err + } + if existing, err := os.ReadFile(path); err == nil { // #nosec G304 -- confined state root. + var previous authority.FleetAppsReport + if json.Unmarshal(existing, &previous) == nil && sameObservedApps(previous, report) { + return nil + } + } + encoded, err := json.MarshalIndent(report, "", " ") + if err != nil { + return err + } + return atomicWriteSecureBytes(path, append(encoded, '\n')) +} + +// sameObservedApps compares two inventories ignoring observation timestamps, +// which advance on every tick regardless of whether anything happened. +func sameObservedApps(previous, current authority.FleetAppsReport) bool { + if previous.Version != current.Version || previous.TenantID != current.TenantID || + previous.AgentID != current.AgentID || len(previous.Apps) != len(current.Apps) { + return false + } + for index := range current.Apps { + before, after := previous.Apps[index], current.Apps[index] + if before.ID != after.ID || before.Version != after.Version || before.Status != after.Status || + before.Detail != after.Detail || before.BinarySHA256 != after.BinarySHA256 || + len(before.DeclaredGrants) != len(after.DeclaredGrants) { + return false + } + for grantIndex := range after.DeclaredGrants { + if before.DeclaredGrants[grantIndex] != after.DeclaredGrants[grantIndex] { + return false + } + } + } + return true +} + +func readAppManifest(directory string) (appmanifest.Manifest, error) { + raw, err := os.ReadFile(filepath.Join(directory, "manifest.json")) // #nosec G304 -- directory is confined to a managed app root. + if err != nil { + return appmanifest.Manifest{}, err + } + parsed, err := appmanifest.Parse(raw) + if err != nil { + return appmanifest.Manifest{}, err + } + if errs := parsed.Validate(); len(errs) > 0 { + return appmanifest.Manifest{}, fmt.Errorf("manifest validation: %v", errs[0]) + } + return *parsed, nil +} + +func manifestGrants(parsed appmanifest.Manifest) []authority.FleetAppGrant { + grants := make([]authority.FleetAppGrant, 0, len(parsed.Grants)) + for _, grant := range parsed.Grants { + grants = append(grants, authority.FleetAppGrant{Cap: grant.Cap, Target: grant.Target}) + } + sort.Slice(grants, func(i, j int) bool { + if grants[i].Cap != grants[j].Cap { + return grants[i].Cap < grants[j].Cap + } + return grants[i].Target < grants[j].Target + }) + return grants +} + +// managedAppIDs lists app directories under a root the runtime fully owns. +func managedAppIDs(root string) []string { + entries, err := os.ReadDir(root) + if err != nil { + return nil + } + ids := make([]string, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() && !strings.HasPrefix(entry.Name(), ".") { + ids = append(ids, entry.Name()) + } + } + sort.Strings(ids) + return ids +} + +// previouslyManaged returns the apps this runtime installed on an earlier pass +// that the authority no longer wants. Tracking what management placed is what +// keeps an operator's hand-installed apps out of scope for removal. +func previouslyManaged(managed map[string]struct{}, wanted map[string]struct{}) []string { + stale := make([]string, 0, len(managed)) + for appID := range managed { + if _, keep := wanted[appID]; !keep { + stale = append(stale, appID) + } + } + sort.Strings(stale) + return stale +} + +// withinDirectory reports whether candidate sits inside parent. It is used to +// keep the staging root outside the supervisor's scan root, so the guard has +// to resist a relative path that climbs back in. +func withinDirectory(parent, candidate string) bool { + absParent, parentErr := filepath.Abs(parent) + absCandidate, candidateErr := filepath.Abs(candidate) + if parentErr != nil || candidateErr != nil { + return false + } + relative, err := filepath.Rel(absParent, absCandidate) + if err != nil { + return false + } + return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) +} diff --git a/internal/enterprisecontrol/fleet_apps_test.go b/internal/enterprisecontrol/fleet_apps_test.go new file mode 100644 index 00000000..b2936fd4 --- /dev/null +++ b/internal/enterprisecontrol/fleet_apps_test.go @@ -0,0 +1,471 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package enterprisecontrol + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/pilot-protocol/pilotprotocol/internal/managedsdk/authority" +) + +// fakeInstaller stands in for the pilotctl subprocess. It plants a manifest +// declaring whatever grants the test wants, which is the only thing the +// reconciler reads out of an installed bundle. +type fakeInstaller struct { + grants map[string][]authority.FleetAppGrant + version map[string]string + failFor map[string]bool + installLog []string + removeLog []string +} + +func newFakeInstaller() *fakeInstaller { + return &fakeInstaller{ + grants: map[string][]authority.FleetAppGrant{}, + version: map[string]string{}, + failFor: map[string]bool{}, + } +} + +func (installer *fakeInstaller) Install(_ context.Context, appID, version, root string) error { + installer.installLog = append(installer.installLog, appID+"@"+version+"->"+filepath.Base(root)) + if installer.failFor[appID] { + return fmt.Errorf("simulated install failure") + } + if planted, ok := installer.version[appID]; ok { + version = planted + } + directory := filepath.Join(root, appID) + if err := os.MkdirAll(directory, 0o700); err != nil { + return err + } + grants := make([]map[string]string, 0, len(installer.grants[appID])) + for _, grant := range installer.grants[appID] { + grants = append(grants, map[string]string{"cap": grant.Cap, "target": grant.Target}) + } + manifest := map[string]any{ + "manifest_version": 1, + "id": appID, + "app_version": version, + "name": "Test App", + "description": "A test app used by the reconciler unit tests.", + "binary": map[string]string{"runtime": "go", "path": "app", "sha256": "aa" + repeat("0", 62)}, + "grants": grants, + "store": map[string]string{"publisher": "ed25519:" + repeat("A", 44), "signature": repeat("B", 64)}, + } + encoded, err := json.Marshal(manifest) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(directory, "manifest.json"), encoded, 0o600); err != nil { + return err + } + return os.WriteFile(filepath.Join(directory, "app"), []byte("#!/bin/sh\n"), 0o700) +} + +func (installer *fakeInstaller) Remove(_ context.Context, appID, root string) error { + installer.removeLog = append(installer.removeLog, appID+"<-"+filepath.Base(root)) + return os.RemoveAll(filepath.Join(root, appID)) +} + +func repeat(s string, n int) string { + out := "" + for i := 0; i < n; i++ { + out += s + } + return out +} + +func newAppsTestRuntime(t *testing.T) (*Runtime, string, string, string) { + t.Helper() + base := t.TempDir() + stateRoot := filepath.Join(base, "state") + installRoot := filepath.Join(base, "apps") + stagingRoot := filepath.Join(base, "apps-pending") + for _, directory := range []string{stateRoot, installRoot, stagingRoot} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + } + runtime := &Runtime{ + tenantID: "tenant-a", rolloutAgentID: "agent-a", + fleetStateEnabled: true, fleetStateRoot: stateRoot, + appsEnabled: true, appsInstallRoot: installRoot, appsStagingRoot: stagingRoot, + appsInterval: 30 * time.Second, appsManaged: map[string]struct{}{}, + } + return runtime, stateRoot, installRoot, stagingRoot +} + +func writeDesired(t *testing.T, stateRoot string, specs ...authority.FleetAppSpec) { + t.Helper() + document := authority.FleetAppsDocument{ + Version: authority.FleetAppsVersion, TenantID: "tenant-a", AgentID: "agent-a", + Desired: specs, Reason: "unit test desired set", IssuedAt: time.Now().Unix(), + } + document.Normalize() + if err := document.Validate(); err != nil { + t.Fatalf("desired document invalid: %v", err) + } + encoded, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), append(encoded, '\n'), 0o600); err != nil { + t.Fatal(err) + } +} + +func readObserved(t *testing.T, stateRoot string) map[string]authority.FleetAppState { + t.Helper() + raw, err := os.ReadFile(filepath.Join(stateRoot, authority.FleetAppsReportPath)) + if err != nil { + t.Fatalf("read observed report: %v", err) + } + var report authority.FleetAppsReport + if err := json.Unmarshal(raw, &report); err != nil { + t.Fatal(err) + } + if err := report.Validate(); err != nil { + t.Fatalf("observed report invalid: %v", err) + } + states := make(map[string]authority.FleetAppState, len(report.Apps)) + for _, state := range report.Apps { + states[state.ID] = state + } + return states +} + +// An app the tenant has not reviewed must land in staging, never in the +// supervisor's scan root. This is the whole grant boundary. +func TestReconcileHoldsUnreviewedAppOutOfInstallRoot(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}} + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if result.Staged != 1 || result.Installed != 0 { + t.Fatalf("expected one staged and none installed, got %+v", result) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("unreviewed app reached the supervisor's install root") + } + if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("expected staged copy: %v", err) + } + state := readObserved(t, stateRoot)["io.pilot.duckdb"] + if state.Status != authority.FleetAppGrantBlocked { + t.Fatalf("status = %q, want %q", state.Status, authority.FleetAppGrantBlocked) + } + // The report must carry the grants so the console can show them. + if len(state.DeclaredGrants) != 1 || state.DeclaredGrants[0].Cap != "fs.read" { + t.Fatalf("declared grants not reported: %+v", state.DeclaredGrants) + } +} + +// Once the desired document accepts exactly what the manifest declares, the +// app is promoted into the install root and the staged copy is cleaned up. +func TestReconcilePromotesAppOnceGrantsAccepted(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + grants := []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}, {Cap: "audit.log", Target: "*"}} + installer.grants["io.pilot.duckdb"] = grants + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if result.Installed != 1 || result.Staged != 0 { + t.Fatalf("expected one installed, got %+v", result) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("accepted app missing from install root: %v", err) + } + if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("staged copy was not cleaned up after promotion") + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppInstalled { + t.Fatalf("status = %q, want installed", state.Status) + } +} + +// A partial acceptance must not promote. Covering one of two declared grants +// is not covering the manifest. +func TestReconcileRefusesPartialGrantAcceptance(t *testing.T) { + runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{ + {Cap: "fs.read", Target: "$APP/*"}, + {Cap: "net.dial", Target: "api.example.com"}, + } + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", + AcceptedGrants: []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}}, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("app with an uncovered grant was promoted") + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppGrantBlocked { + t.Fatalf("status = %q, want grant_blocked", state.Status) + } +} + +// If a catalogue republish widens an app's grants, a node that already runs it +// must demote it rather than keep running the wider capability set. +func TestReconcileDemotesAppWhenGrantsWiden(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + original := []authority.FleetAppGrant{{Cap: "fs.read", Target: "$APP/*"}} + installer.grants["io.pilot.duckdb"] = original + + spec := authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: original, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + } + writeDesired(t, stateRoot, spec) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("first reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("expected app installed after first pass: %v", err) + } + + // The app is republished asking for more than was accepted. + installer.grants["io.pilot.duckdb"] = append(original, authority.FleetAppGrant{Cap: "proc.exec", Target: "/bin/sh"}) + if err := installer.Install(context.Background(), "io.pilot.duckdb", "1.5.4", installRoot); err != nil { + t.Fatal(err) + } + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("second reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("app with widened grants stayed in the install root") + } + if _, err := os.Stat(filepath.Join(stagingRoot, "io.pilot.duckdb")); err != nil { + t.Fatalf("demoted app should be staged for review: %v", err) + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppGrantBlocked { + t.Fatalf("status = %q, want grant_blocked", state.Status) + } +} + +// Dropping an app from the desired set withdraws it from the node. +func TestReconcileRemovesAppDroppedFromDesiredSet(t *testing.T) { + runtime, stateRoot, installRoot, stagingRoot := newAppsTestRuntime(t) + installer := newFakeInstaller() + grants := []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} + installer.grants["io.pilot.duckdb"] = grants + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("install pass: %v", err) + } + + writeDesired(t, stateRoot) + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("removal pass: %v", err) + } + if result.Removed != 1 { + t.Fatalf("expected one removal, got %+v", result) + } + for _, root := range []string{installRoot, stagingRoot} { + if _, err := os.Stat(filepath.Join(root, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatalf("app still present under %s", root) + } + } +} + +// An app a local operator installed by hand is not management's to remove. +func TestReconcileLeavesUnmanagedAppsAlone(t *testing.T) { + runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + if err := installer.Install(context.Background(), "io.pilot.handrolled", "0.1.0", installRoot); err != nil { + t.Fatal(err) + } + writeDesired(t, stateRoot) + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("reconcile: %v", err) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.handrolled")); err != nil { + t.Fatalf("hand-installed app was removed by management: %v", err) + } +} + +// A desired document addressed to another node must be refused even though it +// arrived through a signed channel — the file also sits on local disk. +func TestReconcileRefusesDocumentForAnotherNode(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + document := authority.FleetAppsDocument{ + Version: authority.FleetAppsVersion, TenantID: "tenant-a", AgentID: "agent-elsewhere", + Reason: "document copied from another machine", IssuedAt: time.Now().Unix(), + } + encoded, err := json.MarshalIndent(document, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(stateRoot, authority.FleetAppsDocumentPath), encoded, 0o600); err != nil { + t.Fatal(err) + } + if _, err := runtime.ReconcileApps(context.Background(), newFakeInstaller()); err == nil { + t.Fatal("expected a document addressed to another node to be refused") + } +} + +// A missing desired document means "manage no apps here", not an error. +func TestReconcileTreatsMissingDocumentAsEmptyDesiredSet(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + result, err := runtime.ReconcileApps(context.Background(), newFakeInstaller()) + if err != nil { + t.Fatalf("reconcile: %v", err) + } + if result.Desired != 0 { + t.Fatalf("expected empty desired set, got %+v", result) + } + if _, err := os.Stat(filepath.Join(stateRoot, authority.FleetAppsReportPath)); err != nil { + t.Fatalf("an empty reconcile must still publish an inventory: %v", err) + } +} + +// A failing install is reported, not fatal, and must not leave the app in the +// install root. +func TestReconcileReportsInstallFailure(t *testing.T) { + runtime, stateRoot, installRoot, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.failFor["io.pilot.duckdb"] = true + + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + result, err := runtime.ReconcileApps(context.Background(), installer) + if err != nil { + t.Fatalf("a failing app must not fail the whole reconcile: %v", err) + } + if result.Failed != 1 { + t.Fatalf("expected one failure, got %+v", result) + } + if _, err := os.Stat(filepath.Join(installRoot, "io.pilot.duckdb")); !os.IsNotExist(err) { + t.Fatal("failed install left an app in the install root") + } + if state := readObserved(t, stateRoot)["io.pilot.duckdb"]; state.Status != authority.FleetAppFailed { + t.Fatalf("status = %q, want failed", state.Status) + } +} + +func TestWithinDirectoryRejectsNestedStagingRoot(t *testing.T) { + if !withinDirectory("/var/pilot/apps", "/var/pilot/apps/pending") { + t.Fatal("nested staging root should be detected") + } + if withinDirectory("/var/pilot/apps", "/var/pilot/apps-pending") { + t.Fatal("sibling staging root must not be treated as nested") + } + if withinDirectory("/var/pilot/apps", "/var/pilot") { + t.Fatal("parent directory must not be treated as nested") + } +} + +// The inventory must not be rewritten when nothing changed. Every rewrite +// bumps the state revision the console fences its mutations on, so a ticking +// timestamp would invalidate an operator's in-flight install. +func TestReconcileDoesNotRewriteUnchangedInventory(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + grants := []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} + installer.grants["io.pilot.duckdb"] = grants + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", AcceptedGrants: grants, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("first reconcile: %v", err) + } + reportPath := filepath.Join(stateRoot, authority.FleetAppsReportPath) + first, err := os.ReadFile(reportPath) + if err != nil { + t.Fatal(err) + } + + // A later pass with identical results must leave the bytes untouched even + // though wall-clock time has advanced. + time.Sleep(1100 * time.Millisecond) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("second reconcile: %v", err) + } + second, err := os.ReadFile(reportPath) + if err != nil { + t.Fatal(err) + } + if string(first) != string(second) { + t.Fatal("unchanged inventory was rewritten, which would churn the state revision") + } +} + +// A real change must still be published. +func TestReconcileRepublishesInventoryOnChange(t *testing.T) { + runtime, stateRoot, _, _ := newAppsTestRuntime(t) + installer := newFakeInstaller() + installer.grants["io.pilot.duckdb"] = []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}} + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("first reconcile: %v", err) + } + if status := readObserved(t, stateRoot)["io.pilot.duckdb"].Status; status != authority.FleetAppGrantBlocked { + t.Fatalf("expected grant_blocked, got %q", status) + } + + // Accept the grants; the inventory must now report installed. + writeDesired(t, stateRoot, authority.FleetAppSpec{ + ID: "io.pilot.duckdb", Version: "1.5.4", + AcceptedGrants: []authority.FleetAppGrant{{Cap: "audit.log", Target: "*"}}, + ApprovedBy: "operator", ApprovedAt: time.Now().Unix(), + }) + if _, err := runtime.ReconcileApps(context.Background(), installer); err != nil { + t.Fatalf("second reconcile: %v", err) + } + if status := readObserved(t, stateRoot)["io.pilot.duckdb"].Status; status != authority.FleetAppInstalled { + t.Fatalf("expected installed after acceptance, got %q", status) + } +} + +// Cross-repo invariant: the console writes these paths through the state +// mutation channel, so the node's own protected-path guard must not refuse +// them. If someone renames a document to include "policy" or "trust", installs +// silently stop working. +func TestAppDocumentPathsAreNotProtected(t *testing.T) { + for _, path := range []string{authority.FleetAppsDocumentPath, authority.FleetAppsReportPath} { + if fleetMutationPathProtected(path) { + t.Fatalf("%q is refused by the node's protected-path guard", path) + } + } +} diff --git a/internal/managedsdk/authority/fleet_apps.go b/internal/managedsdk/authority/fleet_apps.go new file mode 100644 index 00000000..56676a54 --- /dev/null +++ b/internal/managedsdk/authority/fleet_apps.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +package authority + +import ( + "fmt" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +const ( + FleetAppsVersion uint16 = 1 + + // FleetAppsDocumentPath is the desired-app-set document's path relative to + // the node's fleet state root. It is delivered by an ordinary signed + // FleetStateMutation: apps deliberately introduce no new wire protocol and + // no new command vocabulary, so a node that already accepts state + // mutations needs no protocol upgrade to accept apps. + FleetAppsDocumentPath = "apps.json" + + MaxFleetAppsEntries = 64 + MaxFleetAppGrants = 64 + MaxFleetAppReasonSize = 256 +) + +// fleetAppIDPattern mirrors app-store/pkg/manifest idPattern. The authority +// refuses to distribute an identifier the node's manifest validator would +// later reject, so an operator learns at approval time rather than at the +// node's next reconcile. +var fleetAppIDPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9_-]*[a-z0-9])?)+$`) + +// fleetAppVersionPattern mirrors the manifest's simplified semver. +var fleetAppVersionPattern = regexp.MustCompile(`^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$`) + +// FleetAppGrant is one manifest-declared capability that a tenant +// administrator accepted on the fleet's behalf. It carries no authority of its +// own: the node re-reads the installed manifest and refuses to start an app +// whose declared grants are not covered by this accepted set, so a catalogue +// that later widens an app's grants fails closed instead of silently gaining +// capability across the fleet. +type FleetAppGrant struct { + Cap string `json:"cap"` + Target string `json:"target"` +} + +func (grant FleetAppGrant) Validate() error { + if !boundedFleetText(grant.Cap, 64, false) || !boundedFleetText(grant.Target, 512, true) { + return fmt.Errorf("authority: invalid fleet app grant") + } + return nil +} + +// FleetAppSpec is one desired app. Version pins what the node resolves out of +// the publisher-signed catalogue; the bundle's per-platform sha256 stays in +// that catalogue rather than here, because one desired-state document is +// distributed unchanged to a mixed-platform fleet. +type FleetAppSpec struct { + ID string `json:"id"` + Version string `json:"version"` + AcceptedGrants []FleetAppGrant `json:"accepted_grants"` + ApprovedBy string `json:"approved_by"` + ApprovedAt int64 `json:"approved_at"` +} + +func (spec FleetAppSpec) Validate() error { + if !fleetAppIDPattern.MatchString(spec.ID) || len(spec.ID) > 128 { + return fmt.Errorf("authority: invalid fleet app id") + } + if !fleetAppVersionPattern.MatchString(spec.Version) { + return fmt.Errorf("authority: invalid fleet app version for %s", spec.ID) + } + if err := validateIdentifier("fleet app approver", spec.ApprovedBy); err != nil { + return err + } + if spec.ApprovedAt <= 0 { + return fmt.Errorf("authority: fleet app %s carries no approval time", spec.ID) + } + if len(spec.AcceptedGrants) > MaxFleetAppGrants { + return fmt.Errorf("authority: fleet app %s declares too many accepted grants", spec.ID) + } + seen := make(map[string]struct{}, len(spec.AcceptedGrants)) + for _, grant := range spec.AcceptedGrants { + if err := grant.Validate(); err != nil { + return err + } + key := grant.Cap + "\x00" + grant.Target + if _, exists := seen[key]; exists { + return fmt.Errorf("authority: fleet app %s repeats an accepted grant", spec.ID) + } + seen[key] = struct{}{} + } + return nil +} + +// FleetAppsDocument is the complete desired app set for one node. It is +// declarative on purpose: the node reconciles toward it and is free to be +// offline, restarted, or rebuilt from an empty disk in between. A container +// fleet member whose filesystem is discarded on restart converges to the same +// set without the authority replaying anything. +type FleetAppsDocument struct { + Version uint16 `json:"version"` + TenantID string `json:"tenant_id"` + AgentID string `json:"agent_id"` + Desired []FleetAppSpec `json:"desired"` + Reason string `json:"reason"` + IssuedAt int64 `json:"issued_at"` +} + +func (document FleetAppsDocument) Validate() error { + if document.Version != FleetAppsVersion || document.IssuedAt <= 0 { + return fmt.Errorf("authority: invalid fleet apps document") + } + for name, value := range map[string]string{"tenant_id": document.TenantID, "agent_id": document.AgentID} { + if err := validateIdentifier(name, value); err != nil { + return err + } + } + if !boundedFleetText(document.Reason, MaxFleetAppReasonSize, false) || len(strings.TrimSpace(document.Reason)) < 8 { + return fmt.Errorf("authority: invalid fleet apps document reason") + } + if len(document.Desired) > MaxFleetAppsEntries { + return fmt.Errorf("authority: fleet apps document exceeds %d entries", MaxFleetAppsEntries) + } + seen := make(map[string]struct{}, len(document.Desired)) + for _, spec := range document.Desired { + if err := spec.Validate(); err != nil { + return err + } + if _, exists := seen[spec.ID]; exists { + return fmt.Errorf("authority: fleet apps document repeats %s", spec.ID) + } + seen[spec.ID] = struct{}{} + } + return nil +} + +// Normalize orders the desired set so that re-approving an unchanged fleet +// produces a byte-identical document. Without this the console would queue a +// mutation, and the node would report a new revision, every time an operator +// opened the page and pressed save with nothing changed. +func (document *FleetAppsDocument) Normalize() { + sort.Slice(document.Desired, func(i, j int) bool { return document.Desired[i].ID < document.Desired[j].ID }) + for index := range document.Desired { + grants := document.Desired[index].AcceptedGrants + sort.Slice(grants, func(i, j int) bool { + if grants[i].Cap != grants[j].Cap { + return grants[i].Cap < grants[j].Cap + } + return grants[i].Target < grants[j].Target + }) + } +} + +// FleetAppState is one app as the node actually found it, reported back +// through the ordinary fleet state mirror rather than a new channel. +// +// DeclaredGrants is what the installed bundle's manifest actually asks for. +// The catalogue does not publish grants — they exist only inside the signed +// bundle — so the fleet is the only truthful source for them. A node that +// installs an app the tenant has not yet reviewed reports the grants here and +// holds the app unstarted, which lets the console show an operator the real +// capability list before anyone accepts it. +type FleetAppState struct { + ID string `json:"id"` + Version string `json:"version"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + BinarySHA256 string `json:"binary_sha256,omitempty"` + DeclaredGrants []FleetAppGrant `json:"declared_grants,omitempty"` + ObservedAt int64 `json:"observed_at"` +} + +const ( + FleetAppInstalled = "installed" + FleetAppPending = "pending" + FleetAppFailed = "failed" + FleetAppGrantBlocked = "grant_blocked" +) + +func (state FleetAppState) Validate() error { + if !fleetAppIDPattern.MatchString(state.ID) || state.ObservedAt <= 0 { + return fmt.Errorf("authority: invalid fleet app state") + } + switch state.Status { + case FleetAppInstalled, FleetAppPending, FleetAppFailed, FleetAppGrantBlocked: + default: + return fmt.Errorf("authority: invalid fleet app status for %s", state.ID) + } + if state.Version != "" && !fleetAppVersionPattern.MatchString(state.Version) { + return fmt.Errorf("authority: invalid fleet app state version for %s", state.ID) + } + if state.BinarySHA256 != "" && !lowerHexIdentifier(state.BinarySHA256, 64) { + return fmt.Errorf("authority: invalid fleet app binary digest for %s", state.ID) + } + if !boundedFleetText(state.Detail, 512, true) || !utf8.ValidString(state.Detail) { + return fmt.Errorf("authority: invalid fleet app state detail for %s", state.ID) + } + if len(state.DeclaredGrants) > MaxFleetAppGrants { + return fmt.Errorf("authority: fleet app %s reports too many declared grants", state.ID) + } + for _, grant := range state.DeclaredGrants { + if err := grant.Validate(); err != nil { + return err + } + } + return nil +} + +// FleetAppsReport is the node-authored inventory document. The node writes it +// into its own state tree, so it arrives through the existing signed snapshot +// and needs no separate endpoint, storage, or retention policy. +type FleetAppsReport struct { + Version uint16 `json:"version"` + TenantID string `json:"tenant_id"` + AgentID string `json:"agent_id"` + Apps []FleetAppState `json:"apps"` + ObservedAt int64 `json:"observed_at"` +} + +// FleetAppsReportPath is where the node publishes its inventory. It is +// deliberately distinct from FleetAppsDocumentPath: the authority owns the +// desired set and the node owns the observed set, so neither overwrites the +// other and drift between them is visible rather than resolved silently. +const FleetAppsReportPath = "apps-observed.json" + +func (report FleetAppsReport) Validate() error { + if report.Version != FleetAppsVersion || report.ObservedAt <= 0 { + return fmt.Errorf("authority: invalid fleet apps report") + } + for name, value := range map[string]string{"tenant_id": report.TenantID, "agent_id": report.AgentID} { + if err := validateIdentifier(name, value); err != nil { + return err + } + } + if len(report.Apps) > MaxFleetAppsEntries { + return fmt.Errorf("authority: fleet apps report exceeds %d entries", MaxFleetAppsEntries) + } + for _, state := range report.Apps { + if err := state.Validate(); err != nil { + return err + } + } + return nil +} + +// GrantsCovered reports whether every grant an installed manifest declares is +// covered by what the administrator accepted. The node calls this before it +// lets the supervisor start an app; the authority calls it to show drift in +// the console. Both must agree, so the comparison lives here rather than in +// either caller. +func GrantsCovered(declared, accepted []FleetAppGrant) bool { + allowed := make(map[string]struct{}, len(accepted)) + for _, grant := range accepted { + allowed[grant.Cap+"\x00"+grant.Target] = struct{}{} + } + for _, grant := range declared { + if _, ok := allowed[grant.Cap+"\x00"+grant.Target]; !ok { + return false + } + } + return true +}