Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1113,6 +1113,21 @@ jobs:
run: dotnet test utils/dotnet/GatewayUtils.sln
shell: pwsh

agent-installer-tests:
name: Agent installer tests
runs-on: windows-2022
needs: [preflight]

steps:
- name: Checkout ${{ github.repository }}
uses: actions/checkout@v6
with:
ref: ${{ needs.preflight.outputs.ref }}

- name: Tests
run: dotnet test package/AgentWindowsManaged.Tests/DevolutionsAgent.Installer.Tests.csproj
shell: pwsh


winapi-sanitizer-tests:
name: Windows API sanitizer tests
Expand Down Expand Up @@ -1357,7 +1372,7 @@ jobs:
success:
name: Success
if: ${{ always() }}
needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier]
needs: [tests, agent-tunnel-e2e, agent-policy-e2e, lints, check-dependencies, jetsocat-lipo, devolutions-gateway-powershell, devolutions-gateway, devolutions-gateway-merge, devolutions-pedm-desktop, devolutions-agent, devolutions-agent-merge, devolutions-pedm-client, dotnet-utils-tests, agent-installer-tests, winapi-sanitizer-tests, winapi-miri, pedm-simulator, secure-memory-verifier]
runs-on: ubuntu-latest

steps:
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion crates/now-package-broker/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ now-policy-server-template = "0.5"
parking_lot = "0.12"
regex = "1"
semver = "1"
serde_json = "1"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["raw_value"] }
sha2 = "0.10"
tokio = { version = "1.52", features = ["net", "io-util", "rt", "macros", "parking_lot", "fs", "sync", "time"] }
tokio-util = "0.7"
Expand Down
64 changes: 64 additions & 0 deletions crates/now-package-broker/src/installer_policy_migration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
//! Installer-only document conversion; callers retain responsibility for source trust and publication.

use anyhow::{Context as _, bail};
use serde::Deserialize;
use serde_json::value::RawValue;

const LEGACY_SCHEMA: &str = "https://devolutions.net/schemas/now-policy.schema.1.0.json";
pub const MAX_DOCUMENT_BYTES: u64 = 1024 * 1024;

#[derive(Deserialize)]
#[serde(rename_all = "PascalCase", deny_unknown_fields)]
struct LegacyDocument<'a> {
#[serde(rename = "$schema")]
schema: String,
policy_version: now_policy::PolicyFormatVersion,
#[serde(borrow)]
policy_type: &'a RawValue,
#[serde(borrow)]
metadata: &'a RawValue,
#[serde(borrow)]
enforcement: &'a RawValue,
#[serde(borrow)]
rules: &'a RawValue,
}

/// Convert a committed legacy document without rewriting its publisher-authored values.
///
/// # Errors
///
/// Rejects oversized, ambiguous, unsupported or invalid documents.
pub fn convert_document(input: &str) -> anyhow::Result<String> {
if input.len() as u64 > MAX_DOCUMENT_BYTES {
bail!("policy exceeds the installer migration size limit");
}
// Parse typed text, not a Value: duplicate fields must not be collapsed.
let output = if now_policy::schema::parse_policy_json(input).is_ok() {
input.to_owned()
} else {
let legacy: LegacyDocument<'_> =
serde_json::from_str(input).context("invalid or unsupported legacy policy document")?;
if legacy.schema != LEGACY_SCHEMA {
bail!("unsupported legacy policy schema");
}
// Keep raw values: reserializing typed metadata would normalize timestamps,
// optional fields and sets, changing the publisher's original content.
format!(
"{{\"PolicyFormatVersion\":{},\"PolicyType\":{},\"Metadata\":{},\"Enforcement\":{},\"Rules\":{}}}",
serde_json::to_string(&legacy.policy_version)?,
legacy.policy_type,
legacy.metadata,
legacy.enforcement,
legacy.rules,
)
};
let policy = now_policy::schema::parse_policy_json(&output).map_err(anyhow::Error::msg)?;
let validation = crate::policy_store::validation::validate_committed_policy(&policy);
if !validation.is_valid {
bail!(
"converted policy failed authoritative validation: {:?}",
validation.findings
);
}
Ok(output)
}
2 changes: 2 additions & 0 deletions crates/now-package-broker/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pub mod event_channel;
#[cfg(windows)]
pub mod executor;
#[cfg(windows)]
pub mod installer_policy_migration;
#[cfg(windows)]
pub mod operation_tracker;
#[cfg(windows)]
pub mod pipe;
Expand Down
2 changes: 1 addition & 1 deletion crates/now-package-broker/src/policy_store/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use now_policy_api::{
};

mod receipt;
mod validation;
pub(crate) mod validation;
mod windows;

#[derive(Clone, Copy, Debug)]
Expand Down
2 changes: 1 addition & 1 deletion crates/now-package-broker/src/policy_store/validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ pub(super) fn validate_draft(raw: &serde_json::Value) -> PolicyValidationResult
}
}
}
pub(super) fn validate_committed_policy(policy: &now_policy::PolicyDocument) -> PolicyValidationResult {
pub(crate) fn validate_committed_policy(policy: &now_policy::PolicyDocument) -> PolicyValidationResult {
let raw = serde_json::to_value(policy.to_draft()).expect("committed policy draft serializes");
validate_draft(&raw)
}
Expand Down
22 changes: 22 additions & 0 deletions devolutions-agent/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,28 @@ fn parse_up_command_args_with_reader<R: BufRead>(args: &[String], mut stdin_read
}

fn main() {
#[cfg(windows)]
if env::args().nth(1).as_deref() == Some("installer-policy-convert") {
use std::io::{Read as _, Write as _};

let result = (|| -> Result<()> {
if env::args().count() != 2 {
bail!("installer-policy-convert accepts only policy JSON on stdin");
}
let mut input = String::new();
io::stdin()
.take(now_package_broker::installer_policy_migration::MAX_DOCUMENT_BYTES + 1)
.read_to_string(&mut input)?;
let output = now_package_broker::installer_policy_migration::convert_document(&input)?;
io::stdout().lock().write_all(output.as_bytes())?;
Ok(())
})();
if let Err(error) = result {
eprintln!("{error:#}");
std::process::exit(1);
}
return;
}
let mut controller = Controller::new(SERVICE_NAME, DISPLAY_NAME, DESCRIPTION);

if let Some(cmd) = env::args().nth(1) {
Expand Down
44 changes: 44 additions & 0 deletions docs/agent-policy-migration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Agent policy format migration

Upgrade with the Windows MSI to migrate an eligible legacy JSON policy from `%ProgramData%\Devolutions\Agent\package-broker-policy.json` to managed PackageBroker storage.
The installer retains the source through a no-follow handle, checks its owner and write permissions, and converts it before publication.
Unsafe paths, untrusted sources and YAML/YML files remain untouched and require administrator remediation.
An existing managed policy is never overwritten, including one that appears during migration.

Conversion accepts only the old committed-document shape: `$schema` must equal `https://devolutions.net/schemas/now-policy.schema.1.0.json`, and `PolicyVersion` must be a canonical, supported `1.minor.patch` version.
It removes `$schema` and renames `PolicyVersion` to `PolicyFormatVersion`, preserving the version rather than relabeling a compatible document as `1.0.0`.
All other JSON values retain their raw representation, including policy identity, publisher, revision, timestamps, validity, rules and order.
Mixed identities, duplicate or unknown fields, malformed JSON and unsupported versions fail conversion.
The official new-contract parser and the broker's committed-policy validator must both accept the result; failure aborts migration and preserves the source.
The ordinary broker reader does not accept either legacy identity field.

## Recovery and downgrade

For a converted policy, the installer keeps the legacy source and a protected `.legacy-policy-migration-<install-id>.marker.original` backup in `%ProgramData%\Devolutions\PackageBroker`.
Its protected marker records the source identity, SHA-256 digest and security descriptor, backup identity, converted-file identity and digest, and migration-owned managed-authority identity.
Neither commit nor rollback deletes the original backup.
The backup is recovery evidence, not an active policy.

Rollback removes only the unchanged migration-owned destination and authority marker, after verifying the retained legacy source and backup.
This restores legacy-path selection for an older Agent without leaving a new-format policy that it cannot parse.
Changed files, replacement authority markers and unverifiable evidence are preserved for manual recovery instead of being overwritten or deleted.
A repeated invocation leaves an existing destination alone; an interrupted invocation with the same install ID can undo its owned authority marker and retry.
An incomplete backup or marker, an unrelated managed-authority marker, or a different transaction's evidence requires manual recovery.
Do not remove the last verified original or a runtime authority marker merely to make installation succeed.

Before a later downgrade, stop the Agent and archive the active managed policy and recovery evidence.
The preserved legacy policy represents the state at migration, not subsequent policy edits.
Have an administrator verify that policy before restoring an old Agent, and resolve managed-path selection explicitly; retaining the backup does not automatically reverse later policy changes.

## Portable, manual and import installations

These entry points do not perform MSI migration.
A legacy document remains **Invalid**, not Missing and not a default policy; requests containing legacy identity fields are rejected.
An explicitly configured legacy `PackageBroker.PolicyPath` also remains Invalid until the administrator changes the configured document or selects the validated managed policy.

Preserve the original bytes and permissions before remediation.
On a trusted local copy, verify the canonical old schema and compatible version, remove `$schema`, and rename `PolicyVersion` without changing metadata or rules.
Use the official contract and broker validation to check the result before replacing an active policy through its supported administrative workflow.
Do not use a parser that discards duplicate keys, silently drops unknown fields or substitutes defaults.
The internal `installer-policy-convert` Agent command accepts JSON on stdin and emits validated JSON on stdout, but does not establish source trust or publish files; it is not a general import or automatic recovery API.
YAML/YML needs a separate administrator-reviewed conversion to strict JSON.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<LangVersion>latest</LangVersion>
<IsPackable>false</IsPackable>
<AssemblyName>DevolutionsAgent.Installer.Tests</AssemblyName>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.1.0" />
<PackageReference Include="xunit" Version="2.4.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.4.3">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AgentWindowsManaged\DevolutionsAgent.csproj" Targets="CoreBuild;GetTargetPath" />
</ItemGroup>
</Project>
Loading
Loading