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 +}