From 9cd5fa0116cf8323d00a31887837248cf8f90b03 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Mon, 15 Jun 2026 23:44:19 +0300 Subject: [PATCH 1/4] feat(unigetui): add package broker policy libraries Add the shared UniGetUI package broker policy model as a standalone Rust crate with schema generation, sample fixtures, and validation tests. Add the matching .NET policy library and tests that parse/create policies and validate them against the Rust-generated schema. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitattributes | 5 + Cargo.lock | 14 + Cargo.toml | 1 + .../crates/uniget-broker-policy/Cargo.toml | 24 + .../samples/corporate-allowlist.policy.json | 134 ++++ .../samples/corporate-allowlist.policy.yaml | 52 ++ .../samples/deny-risky-options.policy.json | 81 +++ .../invalid-failure-decision.policy.json | 28 + .../samples/powershell-advanced.policy.json | 82 +++ .../powershell-current-user.policy.json | 91 +++ .../samples/scenario-coverage.policy.json | 257 +++++++ .../unigetui.package-policy.schema.json | 626 ++++++++++++++++++ .../crates/uniget-broker-policy/src/enums.rs | 114 ++++ .../crates/uniget-broker-policy/src/lib.rs | 18 + .../uniget-broker-policy/src/markers.rs | 86 +++ .../uniget-broker-policy/src/newtypes.rs | 337 ++++++++++ .../crates/uniget-broker-policy/src/policy.rs | 385 +++++++++++ .../crates/uniget-broker-policy/src/schema.rs | 26 + .../tests/policy_samples.rs | 84 +++ .../tools/generate_schema.rs | 35 + unigetui/dotnet/.gitignore | 2 + .../Devolutions.UniGetUI.Broker.Client.slnx | 4 + ...utions.UniGetUI.Broker.Policy.Tests.csproj | 23 + .../PolicyTests.cs | 81 +++ .../Devolutions.UniGetUI.Broker.Policy.csproj | 32 + .../Enums.cs | 62 ++ .../PolicyJson.cs | 19 + .../PolicyModels.cs | 300 +++++++++ 28 files changed, 3003 insertions(+) create mode 100644 unigetui/crates/uniget-broker-policy/Cargo.toml create mode 100644 unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.json create mode 100644 unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.yaml create mode 100644 unigetui/crates/uniget-broker-policy/assets/samples/deny-risky-options.policy.json create mode 100644 unigetui/crates/uniget-broker-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json create mode 100644 unigetui/crates/uniget-broker-policy/assets/samples/powershell-advanced.policy.json create mode 100644 unigetui/crates/uniget-broker-policy/assets/samples/powershell-current-user.policy.json create mode 100644 unigetui/crates/uniget-broker-policy/assets/samples/scenario-coverage.policy.json create mode 100644 unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json create mode 100644 unigetui/crates/uniget-broker-policy/src/enums.rs create mode 100644 unigetui/crates/uniget-broker-policy/src/lib.rs create mode 100644 unigetui/crates/uniget-broker-policy/src/markers.rs create mode 100644 unigetui/crates/uniget-broker-policy/src/newtypes.rs create mode 100644 unigetui/crates/uniget-broker-policy/src/policy.rs create mode 100644 unigetui/crates/uniget-broker-policy/src/schema.rs create mode 100644 unigetui/crates/uniget-broker-policy/tests/policy_samples.rs create mode 100644 unigetui/crates/uniget-broker-policy/tools/generate_schema.rs create mode 100644 unigetui/dotnet/.gitignore create mode 100644 unigetui/dotnet/Devolutions.UniGetUI.Broker.Client.slnx create mode 100644 unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/Devolutions.UniGetUI.Broker.Policy.Tests.csproj create mode 100644 unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs create mode 100644 unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Devolutions.UniGetUI.Broker.Policy.csproj create mode 100644 unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Enums.cs create mode 100644 unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyJson.cs create mode 100644 unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs diff --git a/.gitattributes b/.gitattributes index 3eb52bd81..f55140188 100644 --- a/.gitattributes +++ b/.gitattributes @@ -14,3 +14,8 @@ devolutions-gateway/openapi/dotnet-client/docs/** linguist-generated merge=binar devolutions-gateway/openapi/dotnet-subscriber/src/** linguist-generated merge=binary devolutions-gateway/openapi/ts-angular-client/api/** linguist-generated merge=binary devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge=binary + +# Sample assets and schema files produce huge LoC counts; exclude them from language statistics and +# treat them as generated files. +unigetui/crates/uniget-broker-policy/assets/** linguist-generated +unigetui/crates/uniget-broker-policy/schema/** linguist-generated diff --git a/Cargo.lock b/Cargo.lock index 427810a10..ee0dacc78 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8023,6 +8023,20 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unigetui-broker-policy" +version = "0.1.0" +dependencies = [ + "chrono", + "schemars", + "semver", + "serde", + "serde_json", + "serde_yaml", + "thiserror 2.0.18", + "url", +] + [[package]] name = "universal-hash" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 3c3e60b02..937b746d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "jetsocat", "testsuite", "tools/generate-openapi", + "unigetui/crates/*", ] default-members = [ "devolutions-agent", diff --git a/unigetui/crates/uniget-broker-policy/Cargo.toml b/unigetui/crates/uniget-broker-policy/Cargo.toml new file mode 100644 index 000000000..e8c4eaf5a --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "unigetui-broker-policy" +version = "0.1.0" +edition = "2024" +authors = ["Devolutions Inc. "] +description = "UniGetUI package broker policy model and schema" +publish = false + +[lints] +workspace = true + +[dependencies] +chrono = { version = "0.4", features = ["serde"] } +schemars = { version = "0.8", features = ["chrono"] } +semver = "1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +thiserror = "2" +url = "2" + +[[bin]] +name = "generate-unigetui-broker-policy-schema" +path = "tools/generate_schema.rs" diff --git a/unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.json b/unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.json new file mode 100644 index 000000000..fe788a436 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.json @@ -0,0 +1,134 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.standard-allowlist", + "Publisher": "Contoso IT", + "Revision": 4, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Fail-closed policy for standard workstation package installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Enabled": true, + "Priority": 10, + "Decision": "Deny", + "Reason": "Integrity and publisher checks cannot be bypassed by brokered requests.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.custom-parameters", + "Enabled": true, + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters are not allowed in the workstation allow list.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Enabled": true, + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are not allowed in the workstation allow list.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "allow.winget.vscode", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "Visual Studio Code is approved for managed workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.powertoys", + "Enabled": true, + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is approved for developer workstations.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "User", + "Machine" + ], + "Architectures": [ + "X64", + "Arm64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.yaml b/unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.yaml new file mode 100644 index 000000000..20d9519aa --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/assets/samples/corporate-allowlist.policy.yaml @@ -0,0 +1,52 @@ +"$schema": https://aka.ms/unigetui/package-policy.schema.1.0.json +PolicyVersion: 1.0.0 +PolicyType: PackageBrokerPolicy +Metadata: + Id: contoso.desktop.standard-allowlist-yaml + Publisher: Contoso IT + Revision: 1 + PublishedAt: "2026-05-05T00:00:00Z" + Description: Fail-closed YAML policy for standard workstation package installs. +Enforcement: + DefaultDecision: Deny + RulePrecedence: PriorityThenDeny +Rules: + - Id: deny.integrity-bypass + Enabled: true + Priority: 10 + Decision: Deny + Reason: Integrity and publisher checks cannot be bypassed by brokered requests. + Match: + Operations: + - Install + - Update + SkipHashCheck: + - true + - Id: allow.winget.vscode + Enabled: true + Priority: 100 + Decision: Allow + Reason: Visual Studio Code is approved for managed workstations. + Match: + Operations: + - Install + - Update + Managers: + - Winget + Sources: + - winget + PackageIdentifiers: + - Microsoft.VisualStudioCode + Scopes: + - User + - Machine + Architectures: + - X64 + - Arm64 + Constraints: + AllowInteractive: false + AllowSkipHashCheck: false + AllowPreRelease: false + AllowCustomParameters: false + AllowPrePostCommands: false + AllowKillBeforeOperation: false diff --git a/unigetui/crates/uniget-broker-policy/assets/samples/deny-risky-options.policy.json b/unigetui/crates/uniget-broker-policy/assets/samples/deny-risky-options.policy.json new file mode 100644 index 000000000..ac0896fa8 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/assets/samples/deny-risky-options.policy.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.deny-risky-options", + "Publisher": "Contoso IT", + "Revision": 2, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Default-allow policy that blocks risky broker request options." + }, + "Enforcement": { + "DefaultDecision": "Allow", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.integrity-bypass", + "Priority": 10, + "Decision": "Deny", + "Reason": "Do not broker installs that skip WinGet hash checks or PowerShell publisher checks.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "SkipHashCheck": [ + true + ] + } + }, + { + "Id": "deny.manager-custom-parameters", + "Priority": 20, + "Decision": "Deny", + "Reason": "Custom package-manager parameters require a dedicated exception policy.", + "Match": { + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "deny.prepost-commands", + "Priority": 30, + "Decision": "Deny", + "Reason": "Pre and post operation commands are outside the package manager trust boundary.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "deny.kill-process-actions", + "Priority": 40, + "Decision": "Deny", + "Reason": "Killing processes before a brokered package operation is not allowed by this policy.", + "Match": { + "HasKillBeforeOperation": [ + true + ] + } + }, + { + "Id": "deny.unapproved-winget-source", + "Priority": 50, + "Decision": "Deny", + "Reason": "Only the default WinGet source is accepted by this deny-list sample.", + "Match": { + "Managers": [ + "Winget" + ], + "Sources": [ + "msstore", + "winget-fonts" + ] + } + } + ] +} \ No newline at end of file diff --git a/unigetui/crates/uniget-broker-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json b/unigetui/crates/uniget-broker-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json new file mode 100644 index 000000000..9439e34b5 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/assets/samples/invalid/policies/invalid-failure-decision.policy.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.invalid.failure-decision", + "Publisher": "Contoso IT", + "Revision": 1, + "PublishedAt": "2026-05-05T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Allow", + "failureDecision": "Allow", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "allow.everything", + "Priority": 100, + "Decision": "Allow", + "Match": { + "PackageIdentifiers": [ + "*" + ] + } + } + ] +} \ No newline at end of file diff --git a/unigetui/crates/uniget-broker-policy/assets/samples/powershell-advanced.policy.json b/unigetui/crates/uniget-broker-policy/assets/samples/powershell-advanced.policy.json new file mode 100644 index 000000000..9aa4cd8a4 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/assets/samples/powershell-advanced.policy.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.powershell.advanced-scenarios", + "Publisher": "Contoso IT", + "Revision": 1, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "PowerShell policy fixture for source, version range, and update operation coverage." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.powershell.untrusted-source", + "Priority": 5, + "Decision": "Deny", + "Reason": "Only PSGallery is approved for brokered PowerShell module operations.", + "Match": { + "Managers": [ + "PowerShell" + ], + "Sources": [ + "PoshTestGallery" + ] + } + }, + { + "Id": "deny.powershell.prerelease", + "Priority": 10, + "Decision": "Deny", + "Reason": "Prerelease PowerShell modules are not approved in advanced scenarios.", + "Match": { + "Managers": [ + "PowerShell" + ], + "PreRelease": [ + true + ] + } + }, + { + "Id": "allow.powershell.pester.versioned", + "Priority": 100, + "Decision": "Allow", + "Reason": "Pester is approved from PSGallery for CurrentUser install and update operations within the supported version range.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "PowerShell" + ], + "Sources": [ + "PSGallery" + ], + "PackageIdentifiers": [ + "Pester" + ], + "VersionRange": { + "MinVersion": "5.0.0", + "MaxVersion": "6.0.0", + "IncludePrerelease": false + }, + "Scopes": [ + "User" + ] + }, + "Constraints": { + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/unigetui/crates/uniget-broker-policy/assets/samples/powershell-current-user.policy.json b/unigetui/crates/uniget-broker-policy/assets/samples/powershell-current-user.policy.json new file mode 100644 index 000000000..36af55b7d --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/assets/samples/powershell-current-user.policy.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.powershell.current-user-modules", + "Publisher": "Contoso IT", + "Revision": 3, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "PowerShell Gallery module policy for non-admin CurrentUser installs." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.powershell.machine-scope", + "Priority": 10, + "Decision": "Deny", + "Reason": "PowerShell module installs through the broker must use CurrentUser scope.", + "Match": { + "Managers": [ + "PowerShell" + ], + "Scopes": [ + "Machine" + ] + } + }, + { + "Id": "deny.powershell.elevated", + "Priority": 20, + "Decision": "Deny", + "Reason": "PowerShell module installs must not request an elevated broker context.", + "Match": { + "Managers": [ + "PowerShell" + ], + "Elevation": [ + "Elevated" + ] + } + }, + { + "Id": "deny.powershell.prerelease", + "Priority": 30, + "Decision": "Deny", + "Reason": "Prerelease PowerShell modules are not approved.", + "Match": { + "Managers": [ + "PowerShell" + ], + "PreRelease": [ + true + ] + } + }, + { + "Id": "allow.powershell.pester", + "Priority": 100, + "Decision": "Allow", + "Reason": "Pester is approved from PSGallery for CurrentUser installs.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "PowerShell" + ], + "Sources": [ + "PSGallery" + ], + "PackageIdentifiers": [ + "Pester" + ], + "Scopes": [ + "User" + ] + }, + "Constraints": { + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/unigetui/crates/uniget-broker-policy/assets/samples/scenario-coverage.policy.json b/unigetui/crates/uniget-broker-policy/assets/samples/scenario-coverage.policy.json new file mode 100644 index 000000000..0447ec9cc --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/assets/samples/scenario-coverage.policy.json @@ -0,0 +1,257 @@ +{ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "contoso.desktop.scenario-coverage", + "Publisher": "Contoso IT", + "Revision": 1, + "PublishedAt": "2026-05-05T00:00:00Z", + "Description": "Focused policy used to exercise simulator precedence, version, and constraint behavior." + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + "Rules": [ + { + "Id": "deny.disabled-powertoys", + "Enabled": false, + "Priority": 1, + "Decision": "Deny", + "Reason": "Disabled rules must not participate in policy decisions.", + "Match": { + "Managers": [ + "Winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ] + } + }, + { + "Id": "deny.interactive", + "Priority": 5, + "Decision": "Deny", + "Reason": "Interactive brokered installs are not allowed in the scenario coverage policy.", + "Match": { + "Interactive": [ + true + ] + } + }, + { + "Id": "deny.tie.custom-parameters", + "Priority": 10, + "Decision": "Deny", + "Reason": "Deny must win when allow and deny rules match at the same priority.", + "Match": { + "Managers": [ + "Winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "HasCustomParameters": [ + true + ] + } + }, + { + "Id": "allow.tie.vscode-custom-parameters", + "Priority": 10, + "Decision": "Allow", + "Reason": "This intentionally ties the deny rule to prove deny wins ties.", + "Match": { + "Managers": [ + "Winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "HasCustomParameters": [ + true + ] + }, + "Constraints": { + "AllowCustomParameters": true + } + }, + { + "Id": "deny.prepost-commands", + "Priority": 20, + "Decision": "Deny", + "Reason": "Pre and post commands are outside the package manager trust boundary.", + "Match": { + "HasPrePostCommands": [ + true + ] + } + }, + { + "Id": "deny.kill-process-actions", + "Priority": 30, + "Decision": "Deny", + "Reason": "Killing processes before a brokered operation is not allowed.", + "Match": { + "HasKillBeforeOperation": [ + true + ] + } + }, + { + "Id": "allow.winget.powertoys", + "Priority": 100, + "Decision": "Allow", + "Reason": "PowerToys is allowed and proves disabled deny rules are ignored.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.PowerToys" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.vscode.version-range", + "Priority": 100, + "Decision": "Allow", + "Reason": "VS Code is allowed only within the tested version range.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Microsoft.VisualStudioCode" + ], + "VersionRange": { + "MinVersion": "1.90.0", + "MaxVersion": "2.0.0", + "IncludePrerelease": false + }, + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.git.customized", + "Priority": 100, + "Decision": "Allow", + "Reason": "Git is allowed with tightly constrained customization.", + "Match": { + "Operations": [ + "Install", + "Update" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Git.Git" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomInstallLocation": true, + "AllowedInstallLocationPatterns": [ + "C:\\Tools\\Git*" + ], + "AllowCustomParameters": true, + "AllowedCustomParameters": [ + "--accept-source-agreements" + ], + "DeniedCustomParameters": [ + "--override*" + ], + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + }, + { + "Id": "allow.winget.git.uninstall", + "Priority": 100, + "Decision": "Allow", + "Reason": "Git uninstall is allowed for the same corporate package source and machine scope.", + "Match": { + "Operations": [ + "Uninstall" + ], + "Managers": [ + "Winget" + ], + "Sources": [ + "winget" + ], + "PackageIdentifiers": [ + "Git.Git" + ], + "Scopes": [ + "Machine" + ], + "Architectures": [ + "X64" + ] + }, + "Constraints": { + "AllowInteractive": false, + "AllowSkipHashCheck": false, + "AllowPreRelease": false, + "AllowCustomParameters": false, + "AllowPrePostCommands": false, + "AllowKillBeforeOperation": false + } + } + ] +} \ No newline at end of file diff --git a/unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json b/unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json new file mode 100644 index 000000000..5acdfd95e --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json @@ -0,0 +1,626 @@ +{ + "$id": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "$schema": "http://json-schema.org/draft-07/schema#", + "additionalProperties": false, + "definitions": { + "Architecture": { + "description": "Target architecture.", + "enum": [ + "X86", + "X64", + "Arm64", + "Neutral" + ], + "type": "string" + }, + "CustomParameterString": { + "description": "A custom parameter string.", + "maxLength": 512, + "minLength": 1, + "type": "string" + }, + "Decision": { + "description": "Policy decision.", + "enum": [ + "Allow", + "Deny" + ], + "type": "string" + }, + "Elevation": { + "description": "Requested elevation level.", + "enum": [ + "Standard", + "Elevated" + ], + "type": "string" + }, + "HttpUrl": { + "description": "HTTP(S) URL string.\n\nValidated at deserialization time using the `url` crate.", + "maxLength": 2048, + "pattern": "^([Hh][Tt][Tt][Pp][Ss]?)://.+$", + "type": "string" + }, + "ManagerName": { + "description": "Supported package manager names.", + "enum": [ + "Winget", + "PowerShell", + "PowerShell7" + ], + "type": "string" + }, + "Operation": { + "description": "Package operation type.", + "enum": [ + "Install", + "Update", + "Uninstall" + ], + "type": "string" + }, + "PackageBrokerPolicy": { + "enum": [ + "PackageBrokerPolicy" + ], + "type": "string" + }, + "PolicyConstraints": { + "additionalProperties": false, + "description": "Constraints applied after a rule matches.", + "properties": { + "AllowCustomInstallLocation": { + "description": "Allow custom install location.", + "type": "boolean" + }, + "AllowCustomParameters": { + "description": "Allow custom parameters.", + "type": "boolean" + }, + "AllowInteractive": { + "description": "Allow interactive mode.", + "type": "boolean" + }, + "AllowKillBeforeOperation": { + "description": "Allow killing processes before operation.", + "type": "boolean" + }, + "AllowPrePostCommands": { + "description": "Allow pre/post operation commands.", + "type": "boolean" + }, + "AllowPreRelease": { + "description": "Allow pre-release versions.", + "type": "boolean" + }, + "AllowSkipHashCheck": { + "description": "Allow skipping hash verification.", + "type": "boolean" + }, + "AllowUninstallPrevious": { + "description": "Allow uninstalling previous version before installing update.", + "type": "boolean" + }, + "AllowUpgrade": { + "description": "Allow skipping upgrade on install operations if an existing version is detected (for install operations).", + "type": "boolean" + }, + "AllowedCustomParameterPatterns": { + "description": "Glob patterns for allowed custom parameters.", + "items": { + "$ref": "#/definitions/CustomParameterString" + }, + "maxItems": 128, + "type": "array" + }, + "AllowedCustomParameters": { + "description": "Exact allowed custom parameters.", + "items": { + "$ref": "#/definitions/CustomParameterString" + }, + "maxItems": 128, + "type": "array" + }, + "AllowedInstallLocationPatterns": { + "description": "Glob patterns for allowed install locations.", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 64, + "type": "array" + }, + "DeniedCustomParameters": { + "description": "Denied custom parameters (deny takes precedence over allow).", + "items": { + "$ref": "#/definitions/CustomParameterString" + }, + "maxItems": 128, + "type": "array" + } + }, + "type": "object" + }, + "PolicyEnforcement": { + "additionalProperties": false, + "description": "Enforcement configuration.", + "properties": { + "AuditMode": { + "description": "When true, broker logs decisions but does not enforce.", + "type": [ + "boolean", + "null" + ] + }, + "DefaultDecision": { + "allOf": [ + { + "$ref": "#/definitions/Decision" + } + ], + "description": "Decision when no rule matches." + }, + "RulePrecedence": { + "allOf": [ + { + "$ref": "#/definitions/RulePrecedence" + } + ], + "description": "Rule precedence strategy (must be \"PriorityThenDeny\")." + } + }, + "required": [ + "DefaultDecision", + "RulePrecedence" + ], + "type": "object" + }, + "PolicyMatch": { + "additionalProperties": false, + "description": "Match criteria for a policy rule. All specified fields must match. At least one field must be present.", + "properties": { + "Architectures": { + "description": "Allowed architectures.", + "items": { + "$ref": "#/definitions/Architecture" + }, + "maxItems": 5, + "type": "array", + "uniqueItems": true + }, + "Elevation": { + "description": "Allowed elevation levels.", + "items": { + "$ref": "#/definitions/Elevation" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasCustomInstallLocation": { + "description": "Whether request has custom install location.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasCustomParameters": { + "description": "Whether request has custom parameters.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasKillBeforeOperation": { + "description": "Whether request has kill-before-operation entries.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasPrePostCommands": { + "description": "Whether request has pre/post operation commands.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "HasUninstallPrevious": { + "description": "Whether request has uninstall-previous flag set.", + "items": { + "type": "boolean" + }, + "type": "array", + "uniqueItems": true + }, + "Interactive": { + "description": "Allowed interactive values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "Managers": { + "description": "Allowed managers.", + "items": { + "$ref": "#/definitions/ManagerName" + }, + "maxItems": 16, + "type": "array", + "uniqueItems": true + }, + "Operations": { + "description": "Allowed operations.", + "items": { + "$ref": "#/definitions/Operation" + }, + "maxItems": 3, + "type": "array", + "uniqueItems": true + }, + "PackageIdentifiers": { + "description": "Package identifier patterns (wildcard).", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 1024, + "type": "array", + "uniqueItems": true + }, + "PackageNames": { + "description": "Package name patterns (wildcard).", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 1024, + "type": "array", + "uniqueItems": true + }, + "PreRelease": { + "description": "Allowed preRelease values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "Scopes": { + "description": "Allowed scopes.", + "items": { + "$ref": "#/definitions/Scope" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "SkipHashCheck": { + "description": "Allowed skipHashCheck values.", + "items": { + "type": "boolean" + }, + "maxItems": 2, + "type": "array", + "uniqueItems": true + }, + "Sources": { + "description": "Source patterns (wildcard).", + "items": { + "$ref": "#/definitions/StringPattern" + }, + "maxItems": 128, + "type": "array", + "uniqueItems": true + }, + "VersionRange": { + "anyOf": [ + { + "$ref": "#/definitions/VersionRange" + }, + { + "type": "null" + } + ], + "description": "Semantic version range." + }, + "Versions": { + "description": "Exact version list.", + "items": { + "$ref": "#/definitions/VersionString" + }, + "maxItems": 256, + "type": "array", + "uniqueItems": true + } + }, + "type": "object" + }, + "PolicyMetadata": { + "additionalProperties": false, + "description": "Policy metadata.", + "properties": { + "Description": { + "description": "Human-readable description.", + "maxLength": 512, + "type": [ + "string", + "null" + ] + }, + "Id": { + "allOf": [ + { + "$ref": "#/definitions/ResourceId" + } + ], + "description": "Unique policy identifier." + }, + "PublishedAt": { + "description": "ISO 8601 publication timestamp (RFC 3339).", + "format": "date-time", + "type": "string" + }, + "Publisher": { + "description": "Organization that published the policy.", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "Revision": { + "description": "Monotonically increasing revision number.", + "format": "uint32", + "maximum": 2147483647.0, + "minimum": 1.0, + "type": "integer" + }, + "SupportUrl": { + "anyOf": [ + { + "$ref": "#/definitions/HttpUrl" + }, + { + "type": "null" + } + ], + "description": "URL for support or documentation." + }, + "ValidFrom": { + "description": "Policy becomes active at this time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + }, + "ValidUntil": { + "description": "Policy expires at this time.", + "format": "date-time", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "Id", + "PublishedAt", + "Publisher", + "Revision" + ], + "type": "object" + }, + "PolicyRule": { + "additionalProperties": false, + "description": "A single policy rule.", + "properties": { + "Constraints": { + "anyOf": [ + { + "$ref": "#/definitions/PolicyConstraints" + }, + { + "type": "null" + } + ], + "description": "Additional constraints applied after matching. When absent, no constraints are enforced beyond the match criteria." + }, + "Decision": { + "allOf": [ + { + "$ref": "#/definitions/Decision" + } + ], + "description": "Decision if this rule matches." + }, + "Enabled": { + "default": true, + "description": "Whether the rule is active.", + "type": "boolean" + }, + "Id": { + "allOf": [ + { + "$ref": "#/definitions/ResourceId" + } + ], + "description": "Unique rule identifier." + }, + "Match": { + "allOf": [ + { + "$ref": "#/definitions/PolicyMatch" + } + ], + "description": "Match criteria — request must satisfy all specified fields. At least one criterion must be present." + }, + "Priority": { + "description": "Priority (lower = higher precedence).", + "format": "uint32", + "maximum": 2147483647.0, + "minimum": 0.0, + "type": "integer" + }, + "Reason": { + "description": "Reason reported to the client.", + "maxLength": 512, + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "Decision", + "Id", + "Match", + "Priority" + ], + "type": "object" + }, + "PolicySchemaUri": { + "enum": [ + "https://aka.ms/unigetui/package-policy.schema.1.0.json" + ], + "type": "string" + }, + "ResourceId": { + "description": "Resource identifier (policy IDs, rule IDs, request IDs, audit IDs).", + "maxLength": 128, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._:\\-]{0,127}$", + "type": "string" + }, + "RulePrecedence": { + "description": "Rule precedence strategy — always PriorityThenDeny.", + "enum": [ + "PriorityThenDeny" + ], + "type": "string" + }, + "Scope": { + "description": "Package installation scope.", + "enum": [ + "User", + "Machine" + ], + "type": "string" + }, + "SemanticVersion": { + "description": "Semantic version string (SemVer 2.0.0).\n\nValidated at deserialization time using the `semver` crate.", + "maxLength": 128, + "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\\+([0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", + "type": "string" + }, + "StringPattern": { + "description": "Case-insensitive exact value or wildcard pattern.", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "VersionRange": { + "additionalProperties": false, + "description": "Semantic version range for matching.", + "properties": { + "IncludePrerelease": { + "default": false, + "description": "Whether to include pre-release versions.", + "type": "boolean" + }, + "MaxVersion": { + "description": "Maximum version (inclusive).", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + }, + "MinVersion": { + "description": "Minimum version (inclusive).", + "maxLength": 128, + "minLength": 1, + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "VersionString": { + "description": "A short constrained string for version values.", + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "description": "A policy document governing which package operations are allowed or denied.", + "properties": { + "$schema": { + "allOf": [ + { + "$ref": "#/definitions/PolicySchemaUri" + } + ], + "description": "Policy schema URI constant." + }, + "Enforcement": { + "allOf": [ + { + "$ref": "#/definitions/PolicyEnforcement" + } + ], + "description": "Enforcement configuration." + }, + "Metadata": { + "allOf": [ + { + "$ref": "#/definitions/PolicyMetadata" + } + ], + "description": "Policy metadata." + }, + "PolicyType": { + "allOf": [ + { + "$ref": "#/definitions/PackageBrokerPolicy" + } + ], + "description": "Must be `\"packageBrokerPolicy\"`." + }, + "PolicyVersion": { + "allOf": [ + { + "$ref": "#/definitions/SemanticVersion" + } + ], + "description": "Policy syntax version (semver)." + }, + "Rules": { + "description": "Ordered list of policy rules (may be empty; enforcement defaults apply).", + "items": { + "$ref": "#/definitions/PolicyRule" + }, + "maxItems": 1024, + "type": "array" + } + }, + "required": [ + "$schema", + "Enforcement", + "Metadata", + "PolicyType", + "PolicyVersion", + "Rules" + ], + "title": "PolicyDocument", + "type": "object" +} \ No newline at end of file diff --git a/unigetui/crates/uniget-broker-policy/src/enums.rs b/unigetui/crates/uniget-broker-policy/src/enums.rs new file mode 100644 index 000000000..f363a03d3 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/src/enums.rs @@ -0,0 +1,114 @@ +//! Policy-domain enumerations shared with broker requests. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Package operation type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Operation")] +pub enum Operation { + Install, + Update, + Uninstall, +} + +/// Package installation scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Scope")] +pub enum Scope { + User, + Machine, +} + +/// Target architecture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Architecture")] +pub enum Architecture { + X86, + X64, + Arm64, + Neutral, +} + +/// Supported package manager names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "ManagerName")] +pub enum ManagerName { + Winget, + PowerShell, + PowerShell7, +} + +/// Policy decision. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Decision")] +pub enum Decision { + Allow, + Deny, +} + +/// Requested elevation level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "Elevation")] +pub enum Elevation { + Standard, + Elevated, +} + +impl std::fmt::Display for Decision { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Allow => f.write_str("Allow"), + Self::Deny => f.write_str("Deny"), + } + } +} + +impl std::fmt::Display for Operation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Install => f.write_str("Install"), + Self::Update => f.write_str("Update"), + Self::Uninstall => f.write_str("Uninstall"), + } + } +} + +impl std::fmt::Display for ManagerName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Winget => f.write_str("Winget"), + Self::PowerShell => f.write_str("PowerShell"), + Self::PowerShell7 => f.write_str("PowerShell7"), + } + } +} + +impl std::fmt::Display for Scope { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::User => f.write_str("User"), + Self::Machine => f.write_str("Machine"), + } + } +} + +impl std::fmt::Display for Elevation { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Standard => f.write_str("Standard"), + Self::Elevated => f.write_str("Elevated"), + } + } +} + +impl std::fmt::Display for Architecture { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::X86 => f.write_str("X86"), + Self::X64 => f.write_str("X64"), + Self::Arm64 => f.write_str("Arm64"), + Self::Neutral => f.write_str("Neutral"), + } + } +} diff --git a/unigetui/crates/uniget-broker-policy/src/lib.rs b/unigetui/crates/uniget-broker-policy/src/lib.rs new file mode 100644 index 000000000..b66fc7fab --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/src/lib.rs @@ -0,0 +1,18 @@ +//! UniGetUI package broker policy model and schema helpers. +//! +//! This crate intentionally contains only admin-authored policy types. +//! Broker request, response, server, transport, and execution types live in +//! `unigetui-broker`. + +#![allow(unused_qualifications)] + +pub mod enums; +pub mod markers; +pub mod newtypes; +pub mod policy; +pub mod schema; + +pub use enums::*; +pub use markers::*; +pub use newtypes::*; +pub use policy::*; diff --git a/unigetui/crates/uniget-broker-policy/src/markers.rs b/unigetui/crates/uniget-broker-policy/src/markers.rs new file mode 100644 index 000000000..4099f7a01 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/src/markers.rs @@ -0,0 +1,86 @@ +//! Marker types -- zero-size structs that serialize to a fixed string constant. + +use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec}; +use serde::{Deserialize, Serialize}; + +/// Marker type for policy type: serializes to `"packageBrokerPolicy"`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PackageBrokerPolicy; + +impl Serialize for PackageBrokerPolicy { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str("PackageBrokerPolicy") + } +} + +impl<'de> Deserialize<'de> for PackageBrokerPolicy { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if s == "PackageBrokerPolicy" { + Ok(Self) + } else { + Err(serde::de::Error::custom(format!( + "expected \"PackageBrokerPolicy\", got \"{s}\"" + ))) + } + } +} + +impl JsonSchema for PackageBrokerPolicy { + fn schema_name() -> String { + "PackageBrokerPolicy".to_owned() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + enum_values: Some(vec![serde_json::Value::String("PackageBrokerPolicy".to_owned())]), + ..Default::default() + } + .into() + } +} + +/// Schema URI for package policy documents. +pub const POLICY_SCHEMA_URI: &str = "https://aka.ms/unigetui/package-policy.schema.1.0.json"; + +/// Marker type for the policy `$schema` field. +/// Serializes to the canonical policy schema URI. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PolicySchemaUri; + +impl Serialize for PolicySchemaUri { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(POLICY_SCHEMA_URI) + } +} + +impl<'de> Deserialize<'de> for PolicySchemaUri { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + if s == POLICY_SCHEMA_URI { + Ok(Self) + } else { + Err(serde::de::Error::custom(format!( + "expected \"{POLICY_SCHEMA_URI}\", got \"{s}\"" + ))) + } + } +} + +impl JsonSchema for PolicySchemaUri { + fn schema_name() -> String { + "PolicySchemaUri".to_owned() + } + + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + enum_values: Some(vec![serde_json::Value::String(POLICY_SCHEMA_URI.to_owned())]), + ..Default::default() + } + .into() + } +} diff --git a/unigetui/crates/uniget-broker-policy/src/newtypes.rs b/unigetui/crates/uniget-broker-policy/src/newtypes.rs new file mode 100644 index 000000000..06a0af61a --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/src/newtypes.rs @@ -0,0 +1,337 @@ +//! Schema-validated newtypes used by package broker policy documents. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Error returned when a policy newtype fails deserialization validation. +#[derive(Debug, thiserror::Error)] +pub enum ModelValidationError { + #[error("{type_name}: {reason}")] + Invalid { type_name: &'static str, reason: String }, +} + +fn validate_bounded_string( + s: &str, + min: usize, + max: usize, + type_name: &'static str, +) -> Result<(), ModelValidationError> { + if s.len() < min { + return Err(ModelValidationError::Invalid { + type_name, + reason: format!("length {} is below minimum {min}", s.len()), + }); + } + + if s.len() > max { + return Err(ModelValidationError::Invalid { + type_name, + reason: format!("length {} exceeds maximum {max}", s.len()), + }); + } + + Ok(()) +} + +/// Semantic version string (SemVer 2.0.0). +/// +/// Validated at deserialization time using the `semver` crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct SemanticVersion( + #[schemars( + length(max = 128), + regex( + pattern = r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$" + ) + )] + pub String, +); + +impl SemanticVersion { + pub fn parse(s: &str) -> Result { + if s.len() > 128 { + return Err(ModelValidationError::Invalid { + type_name: "SemanticVersion", + reason: format!("length {} exceeds maximum 128", s.len()), + }); + } + + semver::Version::parse(s).map_err(|e| ModelValidationError::Invalid { + type_name: "SemanticVersion", + reason: e.to_string(), + })?; + + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for SemanticVersion { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for SemanticVersion { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SemanticVersion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for SemanticVersion { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for SemanticVersion { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +/// Resource identifier (policy IDs, rule IDs, request IDs, audit IDs). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct ResourceId( + #[schemars(length(max = 128), regex(pattern = r"^[A-Za-z0-9][A-Za-z0-9._:\-]{0,127}$"))] pub String, +); + +impl ResourceId { + pub fn parse(s: &str) -> Result { + if s.len() > 128 { + return Err(ModelValidationError::Invalid { + type_name: "ResourceId", + reason: format!("length {} exceeds maximum 128", s.len()), + }); + } + + if !is_valid_resource_id(s) { + return Err(ModelValidationError::Invalid { + type_name: "ResourceId", + reason: + "must start with an alphanumeric character and contain only letters, digits, '.', '_', ':' or '-'" + .to_owned(), + }); + } + + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for ResourceId { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +fn is_valid_resource_id(s: &str) -> bool { + if s.is_empty() { + return false; + } + + let bytes = s.as_bytes(); + if !bytes[0].is_ascii_alphanumeric() { + return false; + } + + bytes[1..] + .iter() + .all(|&b| b.is_ascii_alphanumeric() || b == b'.' || b == b'_' || b == b':' || b == b'-') +} + +impl std::ops::Deref for ResourceId { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for ResourceId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for ResourceId { + fn from(s: String) -> Self { + Self(s) + } +} + +impl From<&str> for ResourceId { + fn from(s: &str) -> Self { + Self(s.to_owned()) + } +} + +/// HTTP(S) URL string. +/// +/// Validated at deserialization time using the `url` crate. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct HttpUrl( + #[schemars(length(max = 2048), regex(pattern = r"^([Hh][Tt][Tt][Pp][Ss]?)://.+$"))] + pub String, +); + +impl HttpUrl { + pub fn parse(s: &str) -> Result { + if s.len() > 2048 { + return Err(ModelValidationError::Invalid { + type_name: "HttpUrl", + reason: format!("length {} exceeds maximum 2048", s.len()), + }); + } + + let parsed = url::Url::parse(s).map_err(|e| ModelValidationError::Invalid { + type_name: "HttpUrl", + reason: e.to_string(), + })?; + + match parsed.scheme() { + "http" | "https" => Ok(Self(s.to_owned())), + other => Err(ModelValidationError::Invalid { + type_name: "HttpUrl", + reason: format!("scheme must be http or https, got {other}"), + }), + } + } +} + +impl<'de> Deserialize<'de> for HttpUrl { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for HttpUrl { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for HttpUrl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl From for HttpUrl { + fn from(s: String) -> Self { + Self(s) + } +} + +/// Case-insensitive exact value or wildcard pattern. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct StringPattern(#[schemars(length(min = 1, max = 256))] pub String); + +impl StringPattern { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 256, "StringPattern")?; + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for StringPattern { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for StringPattern { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for StringPattern { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for StringPattern { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +/// A short constrained string for version values. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct VersionString(#[schemars(length(min = 1, max = 128))] pub String); + +impl VersionString { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 128, "VersionString")?; + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for VersionString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for VersionString { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for VersionString { + fn as_ref(&self) -> &str { + &self.0 + } +} + +/// A custom parameter string. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, JsonSchema)] +pub struct CustomParameterString(#[schemars(length(min = 1, max = 512))] pub String); + +impl CustomParameterString { + pub fn parse(s: &str) -> Result { + validate_bounded_string(s, 1, 512, "CustomParameterString")?; + Ok(Self(s.to_owned())) + } +} + +impl<'de> Deserialize<'de> for CustomParameterString { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Self::parse(&s).map_err(serde::de::Error::custom) + } +} + +impl std::ops::Deref for CustomParameterString { + type Target = str; + + fn deref(&self) -> &str { + &self.0 + } +} + +impl AsRef for CustomParameterString { + fn as_ref(&self) -> &str { + &self.0 + } +} diff --git a/unigetui/crates/uniget-broker-policy/src/policy.rs b/unigetui/crates/uniget-broker-policy/src/policy.rs new file mode 100644 index 000000000..ce2531b06 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/src/policy.rs @@ -0,0 +1,385 @@ +//! Policy document models. + +use std::collections::BTreeSet; + +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::{ + Architecture, CustomParameterString, Decision, Elevation, HttpUrl, ManagerName, Operation, PackageBrokerPolicy, + PolicySchemaUri, ResourceId, Scope, SemanticVersion, StringPattern, VersionString, +}; + +/// A policy document governing which package operations are allowed or denied. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyDocument")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyDocument { + /// Policy schema URI constant. + #[serde(rename = "$schema")] + pub _schema: PolicySchemaUri, + + /// Policy syntax version (semver). + pub policy_version: SemanticVersion, + + /// Must be `"packageBrokerPolicy"`. + pub policy_type: PackageBrokerPolicy, + + /// Policy metadata. + pub metadata: PolicyMetadata, + + /// Enforcement configuration. + pub enforcement: PolicyEnforcement, + + /// Ordered list of policy rules (may be empty; enforcement defaults apply). + #[schemars(length(max = 1024))] + pub rules: Vec, +} + +/// Policy metadata. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyMetadata")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyMetadata { + /// Unique policy identifier. + pub id: ResourceId, + + /// Organization that published the policy. + #[schemars(length(min = 1, max = 128))] + pub publisher: String, + + /// Monotonically increasing revision number. + #[schemars(range(min = 1, max = 2147483647))] + pub revision: u32, + + /// ISO 8601 publication timestamp (RFC 3339). + pub published_at: DateTime, + + /// Policy becomes active at this time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_from: Option>, + + /// Policy expires at this time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub valid_until: Option>, + + /// Human-readable description. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 512))] + pub description: Option, + + /// URL for support or documentation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub support_url: Option, +} + +/// Enforcement configuration. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyEnforcement")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyEnforcement { + /// Decision when no rule matches. + pub default_decision: Decision, + + /// Rule precedence strategy (must be "PriorityThenDeny"). + pub rule_precedence: RulePrecedence, + + /// When true, broker logs decisions but does not enforce. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_mode: Option, +} + +/// Rule precedence strategy — always PriorityThenDeny. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "RulePrecedence")] +pub enum RulePrecedence { + PriorityThenDeny, +} + +/// A single policy rule. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyRule")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyRule { + /// Unique rule identifier. + pub id: ResourceId, + + /// Whether the rule is active. + #[serde(default = "default_true")] + pub enabled: bool, + + /// Priority (lower = higher precedence). + #[schemars(range(min = 0, max = 2147483647))] + pub priority: u32, + + /// Decision if this rule matches. + pub decision: Decision, + + /// Reason reported to the client. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(max = 512))] + pub reason: Option, + + /// Match criteria — request must satisfy all specified fields. + /// At least one criterion must be present. + #[serde(rename = "Match", deserialize_with = "deserialize_non_empty_match")] + pub match_criteria: PolicyMatch, + + /// Additional constraints applied after matching. + /// When absent, no constraints are enforced beyond the match criteria. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub constraints: Option, +} + +fn default_true() -> bool { + true +} + +fn deserialize_non_empty_match<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result { + let m = PolicyMatch::deserialize(deserializer)?; + if m.is_empty() { + return Err(serde::de::Error::custom("match must contain at least one criterion")); + } + Ok(m) +} + +/// Match criteria for a policy rule. All specified fields must match. +/// At least one field must be present. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyMatch")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyMatch { + /// Allowed operations. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 3))] + pub operations: BTreeSet, + + /// Allowed managers. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 16))] + pub managers: BTreeSet, + + /// Source patterns (wildcard). + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 128))] + pub sources: BTreeSet, + + /// Package identifier patterns (wildcard). + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 1024))] + pub package_identifiers: BTreeSet, + + /// Package name patterns (wildcard). + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 1024))] + pub package_names: BTreeSet, + + /// Exact version list. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 256))] + pub versions: BTreeSet, + + /// Semantic version range. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_range: Option, + + /// Allowed scopes. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub scopes: BTreeSet, + + /// Allowed architectures. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 5))] + pub architectures: BTreeSet, + + /// Allowed elevation levels. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub elevation: BTreeSet, + + /// Allowed interactive values. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub interactive: BTreeSet, + + /// Allowed skipHashCheck values. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub skip_hash_check: BTreeSet, + + /// Allowed preRelease values. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub pre_release: BTreeSet, + + /// Whether request has custom parameters. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_custom_parameters: BTreeSet, + + /// Whether request has custom install location. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_custom_install_location: BTreeSet, + + /// Whether request has pre/post operation commands. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_pre_post_commands: BTreeSet, + + /// Whether request has kill-before-operation entries. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + #[schemars(length(max = 2))] + pub has_kill_before_operation: BTreeSet, + + /// Whether request has uninstall-previous flag set. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub has_uninstall_previous: BTreeSet, +} + +impl PolicyMatch { + /// Returns true if no criteria are specified. + pub fn is_empty(&self) -> bool { + self.operations.is_empty() + && self.managers.is_empty() + && self.sources.is_empty() + && self.package_identifiers.is_empty() + && self.package_names.is_empty() + && self.versions.is_empty() + && self.version_range.is_none() + && self.scopes.is_empty() + && self.architectures.is_empty() + && self.elevation.is_empty() + && self.interactive.is_empty() + && self.skip_hash_check.is_empty() + && self.pre_release.is_empty() + && self.has_custom_parameters.is_empty() + && self.has_custom_install_location.is_empty() + && self.has_pre_post_commands.is_empty() + && self.has_kill_before_operation.is_empty() + && self.has_uninstall_previous.is_empty() + } +} + +/// Semantic version range for matching. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "VersionRange")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct VersionRange { + /// Minimum version (inclusive). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 128))] + pub min_version: Option, + + /// Maximum version (inclusive). + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(length(min = 1, max = 128))] + pub max_version: Option, + + /// Whether to include pre-release versions. + #[serde(default)] + pub include_prerelease: bool, +} + +/// Constraints applied after a rule matches. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[schemars(rename = "PolicyConstraints")] +#[serde(rename_all = "PascalCase")] +#[serde(deny_unknown_fields)] +pub struct PolicyConstraints { + /// Allow interactive mode. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_interactive: bool, + + /// Allow skipping hash verification. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_skip_hash_check: bool, + + /// Allow pre-release versions. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_pre_release: bool, + + /// Allow custom install location. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_custom_install_location: bool, + + /// Glob patterns for allowed install locations. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 64))] + pub allowed_install_location_patterns: Vec, + + /// Allow custom parameters. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_custom_parameters: bool, + + /// Exact allowed custom parameters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 128))] + pub allowed_custom_parameters: Vec, + + /// Glob patterns for allowed custom parameters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 128))] + pub allowed_custom_parameter_patterns: Vec, + + /// Denied custom parameters (deny takes precedence over allow). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + #[schemars(length(max = 128))] + pub denied_custom_parameters: Vec, + + /// Allow pre/post operation commands. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_pre_post_commands: bool, + + /// Allow killing processes before operation. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_kill_before_operation: bool, + + /// Allow uninstalling previous version before installing update. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_uninstall_previous: bool, + + /// Allow skipping upgrade on install operations if an existing version + /// is detected (for install operations). + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub allow_upgrade: bool, +} + +impl Default for PolicyConstraints { + fn default() -> Self { + Self { + allow_interactive: true, + allow_skip_hash_check: true, + allow_pre_release: true, + allow_custom_install_location: true, + allowed_install_location_patterns: Vec::new(), + allow_custom_parameters: true, + allowed_custom_parameters: Vec::new(), + allowed_custom_parameter_patterns: Vec::new(), + denied_custom_parameters: Vec::new(), + allow_pre_post_commands: true, + allow_kill_before_operation: true, + allow_uninstall_previous: true, + allow_upgrade: true, + } + } +} + +impl PolicyConstraints { + /// Returns true if all fields are at their defaults (fully permissive). + pub fn is_default(&self) -> bool { + *self == Self::default() + } +} + +fn is_true(v: &bool) -> bool { + *v +} diff --git a/unigetui/crates/uniget-broker-policy/src/schema.rs b/unigetui/crates/uniget-broker-policy/src/schema.rs new file mode 100644 index 000000000..d2e26d5a8 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/src/schema.rs @@ -0,0 +1,26 @@ +//! Schema generation and parsing helpers for policy documents. + +use schemars::schema_for; + +use crate::PolicyDocument; + +/// Get the generated policy schema as a JSON value. +pub fn policy_schema_json() -> serde_json::Value { + let schema = schema_for!(PolicyDocument); + serde_json::to_value(&schema).expect("BUG: schema serialization failed") +} + +/// Validate a policy document by deserializing from a JSON value. +pub fn parse_policy(value: serde_json::Value) -> Result { + serde_json::from_value(value).map_err(|e| e.to_string()) +} + +/// Validate a policy document by deserializing from JSON text. +pub fn parse_policy_json(text: &str) -> Result { + serde_json::from_str(text).map_err(|e| e.to_string()) +} + +/// Validate a policy document by deserializing from YAML text. +pub fn parse_policy_yaml(text: &str) -> Result { + serde_yaml::from_str(text).map_err(|e| e.to_string()) +} diff --git a/unigetui/crates/uniget-broker-policy/tests/policy_samples.rs b/unigetui/crates/uniget-broker-policy/tests/policy_samples.rs new file mode 100644 index 000000000..7d0ff15b8 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/tests/policy_samples.rs @@ -0,0 +1,84 @@ +//! Policy model and sample validation tests. + +#![allow(clippy::unwrap_used)] + +use std::path::{Path, PathBuf}; + +use unigetui_broker_policy::PolicyDocument; + +fn samples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("assets/samples") +} + +fn load_policy(path: &Path) -> PolicyDocument { + let content = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display())); + let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); + match ext { + "yaml" | "yml" => serde_yaml::from_str(&content) + .unwrap_or_else(|e| panic!("failed to deserialize YAML policy {}: {e}", path.display())), + _ => serde_json::from_str(&content) + .unwrap_or_else(|e| panic!("failed to deserialize policy {}: {e}", path.display())), + } +} + +#[test] +fn all_sample_policies_deserialize() { + let dir = samples_dir(); + + let policy_files = [ + "corporate-allowlist.policy.json", + "corporate-allowlist.policy.yaml", + "deny-risky-options.policy.json", + "powershell-advanced.policy.json", + "powershell-current-user.policy.json", + "scenario-coverage.policy.json", + ]; + + for file in &policy_files { + let path = dir.join(file); + let _policy = load_policy(&path); + } +} + +#[test] +fn invalid_policy_unknown_field_fails_deserialization() { + let value = serde_json::json!({ + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "test", + "Publisher": "Test", + "Revision": 1, + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny", + "UnknownField": true + }, + "Rules": [] + }); + + let result: Result = serde_json::from_value(value); + assert!(result.is_err(), "policy with unknown field should fail deserialization"); +} + +#[test] +fn invalid_policy_fixture_fails_deserialization() { + let path = samples_dir().join("invalid/policies/invalid-failure-decision.policy.json"); + let content = std::fs::read_to_string(&path).unwrap(); + let result: Result = serde_json::from_str(&content); + assert!(result.is_err(), "invalid policy fixture should fail deserialization"); +} + +#[test] +fn policy_schema_generates_valid_json() { + let schema = unigetui_broker_policy::schema::policy_schema_json(); + assert!(schema.is_object()); + let obj = schema.as_object().unwrap(); + assert!( + obj.contains_key("definitions") || obj.contains_key("$defs"), + "schema should have type definitions" + ); +} diff --git a/unigetui/crates/uniget-broker-policy/tools/generate_schema.rs b/unigetui/crates/uniget-broker-policy/tools/generate_schema.rs new file mode 100644 index 000000000..2134c2999 --- /dev/null +++ b/unigetui/crates/uniget-broker-policy/tools/generate_schema.rs @@ -0,0 +1,35 @@ +//! Generates the JSON schema for the policy document. +//! +//! Usage: `cargo run -p unigetui-broker-policy --bin generate-unigetui-broker-policy-schema` + +#![allow(clippy::print_stdout, reason = "this is a developer-facing CLI tool")] + +use std::path::Path; + +use serde_json::{Map, Value}; +use unigetui_broker_policy::POLICY_SCHEMA_URI; +use unigetui_broker_policy::schema::policy_schema_json; + +fn main() { + let crate_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + let out_path = crate_dir.join("schema").join("unigetui.package-policy.schema.json"); + + let schema = with_id(policy_schema_json(), POLICY_SCHEMA_URI); + let json = serde_json::to_string_pretty(&schema).expect("BUG: schema serialization failed"); + + std::fs::write(&out_path, &json).unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display())); + + println!("Wrote {}", out_path.display()); +} + +fn with_id(schema: Value, id: &str) -> Value { + let Value::Object(existing) = schema else { + panic!("BUG: schema root is not an object"); + }; + + let mut object = Map::new(); + object.insert("$id".to_owned(), Value::String(id.to_owned())); + object.extend(existing); + + Value::Object(object) +} diff --git a/unigetui/dotnet/.gitignore b/unigetui/dotnet/.gitignore new file mode 100644 index 000000000..cd42ee34e --- /dev/null +++ b/unigetui/dotnet/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Client.slnx b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Client.slnx new file mode 100644 index 000000000..21b0d0efb --- /dev/null +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Client.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/Devolutions.UniGetUI.Broker.Policy.Tests.csproj b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/Devolutions.UniGetUI.Broker.Policy.Tests.csproj new file mode 100644 index 000000000..3988a78ee --- /dev/null +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/Devolutions.UniGetUI.Broker.Policy.Tests.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + latest + enable + enable + false + Devolutions.UniGetUI.Broker.Policy.Tests + + + + + + + + + + + + + + diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs new file mode 100644 index 000000000..50047f25f --- /dev/null +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs @@ -0,0 +1,81 @@ +using System.Runtime.CompilerServices; + +using NJsonSchema; + +using Xunit; + +namespace Devolutions.UniGetUI.Broker.Policy.Tests; + +public class PolicyTests +{ + private static string PolicyCrateRoot { get; } = ResolvePolicyCrateRoot(); + + private static string SamplesDir => Path.Combine(PolicyCrateRoot, "assets", "samples"); + + private static string PolicySchema => Path.Combine(PolicyCrateRoot, "schema", "unigetui.package-policy.schema.json"); + + public static IEnumerable PolicySamples() => + Directory.GetFiles(SamplesDir, "*.policy.*").Select(f => new object[] { f }); + + [Theory] + [MemberData(nameof(PolicySamples))] + public async Task Policy_samples_parse_and_validate_against_rust_schema(string path) + { + var policy = ParsePolicy(path); + var schema = await JsonSchema.FromFileAsync(PolicySchema); + var errors = schema.Validate(policy.ToJson()); + + Assert.True( + errors.Count == 0, + $"{Path.GetFileName(path)} failed policy schema validation:\n" + + string.Join("\n", errors.Select(e => $" {e.Kind} at {e.Path}"))); + } + + [Fact] + public async Task Created_policy_validates_against_rust_schema() + { + var policy = PolicyDocument.Create("contoso.policy", "Contoso IT"); + policy.Rules.Add(new PolicyRule + { + Id = "allow.vscode", + Priority = 100, + Decision = Decision.Allow, + Match = new PolicyMatch + { + Operations = [Operation.Install], + Managers = [ManagerName.Winget], + PackageIdentifiers = ["Microsoft.VisualStudioCode"], + }, + }); + + var schema = await JsonSchema.FromFileAsync(PolicySchema); + var errors = schema.Validate(policy.ToJson()); + + Assert.True(errors.Count == 0, string.Join("\n", errors.Select(e => $" {e.Kind} at {e.Path}"))); + } + + [Fact] + public void Invalid_policy_fixture_is_rejected_by_parser() + { + var path = Path.Combine(SamplesDir, "invalid", "policies", "invalid-failure-decision.policy.json"); + var content = File.ReadAllText(path); + + Assert.ThrowsAny(() => PolicyDocument.ParseJson(content)); + } + + private static PolicyDocument ParsePolicy(string path) + { + var content = File.ReadAllText(path); + var extension = Path.GetExtension(path); + return extension.Equals(".yaml", StringComparison.OrdinalIgnoreCase) + || extension.Equals(".yml", StringComparison.OrdinalIgnoreCase) + ? PolicyDocument.ParseYaml(content) + : PolicyDocument.ParseJson(content); + } + + private static string ResolvePolicyCrateRoot([CallerFilePath] string thisFile = "") + { + var testsDir = Path.GetDirectoryName(thisFile)!; + return Path.GetFullPath(Path.Combine(testsDir, "..", "..", "crates", "uniget-broker-policy")); + } +} diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Devolutions.UniGetUI.Broker.Policy.csproj b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Devolutions.UniGetUI.Broker.Policy.csproj new file mode 100644 index 000000000..0f8860a5a --- /dev/null +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Devolutions.UniGetUI.Broker.Policy.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + latest + enable + enable + Devolutions.UniGetUI.Broker.Policy + Devolutions.UniGetUI.Broker.Policy + true + + + + Devolutions.UniGetUI.Broker.Policy + 0.0.0.0 + Devolutions Agent UniGetUI package broker policy model + Policy creation and parsing APIs for the Devolutions Agent UniGetUI package broker. + Devolutions Inc. + © Devolutions Inc. All rights reserved. + MIT OR Apache-2.0 + https://github.com/Devolutions/devolutions-gateway.git + git + true + snupkg + false + + + + + + + diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Enums.cs b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Enums.cs new file mode 100644 index 000000000..b5b6d8c01 --- /dev/null +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/Enums.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace Devolutions.UniGetUI.Broker.Policy; + +/// Package operation type. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Operation +{ + Install, + Update, + Uninstall, +} + +/// Supported package manager names. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ManagerName +{ + Winget, + PowerShell, + PowerShell7, +} + +/// Installation scope. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Scope +{ + User, + Machine, +} + +/// Target architecture. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Architecture +{ + X86, + X64, + Arm64, + Neutral, +} + +/// Requested elevation level. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Elevation +{ + Standard, + Elevated, +} + +/// Policy decision. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum Decision +{ + Allow, + Deny, +} + +/// Rule precedence strategy. +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum RulePrecedence +{ + PriorityThenDeny, +} diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyJson.cs b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyJson.cs new file mode 100644 index 000000000..20062c04e --- /dev/null +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyJson.cs @@ -0,0 +1,19 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Devolutions.UniGetUI.Broker.Policy; + +public static class PolicyJson +{ + public static readonly JsonSerializerOptions Options = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() }, + }; + + public static readonly JsonSerializerOptions StrictOptions = new(Options) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + }; +} diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs new file mode 100644 index 000000000..bf338d1cf --- /dev/null +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs @@ -0,0 +1,300 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; + +using YamlDotNet.Core; +using YamlDotNet.RepresentationModel; + +namespace Devolutions.UniGetUI.Broker.Policy; + +public static class SchemaUris +{ + public const string Policy = "https://aka.ms/unigetui/package-policy.schema.1.0.json"; +} + +/// A policy document governing which package operations are allowed or denied. +public sealed class PolicyDocument +{ + [JsonPropertyName("$schema")] + public string Schema { get; set; } = SchemaUris.Policy; + + [JsonPropertyName("PolicyVersion")] + public string PolicyVersion { get; set; } = "1.0.0"; + + [JsonPropertyName("PolicyType")] + public string PolicyType { get; set; } = "PackageBrokerPolicy"; + + [JsonPropertyName("Metadata")] + public PolicyMetadata Metadata { get; set; } = new(); + + [JsonPropertyName("Enforcement")] + public PolicyEnforcement Enforcement { get; set; } = new(); + + [JsonPropertyName("Rules")] + public List Rules { get; set; } = []; + + public static PolicyDocument Create(string id, string publisher, Decision defaultDecision = Decision.Deny) + { + return new PolicyDocument + { + Metadata = new PolicyMetadata + { + Id = id, + Publisher = publisher, + Revision = 1, + PublishedAt = DateTimeOffset.UtcNow, + }, + Enforcement = new PolicyEnforcement + { + DefaultDecision = defaultDecision, + RulePrecedence = RulePrecedence.PriorityThenDeny, + }, + }; + } + + public static PolicyDocument ParseJson(string json) + { + return JsonSerializer.Deserialize(json, PolicyJson.StrictOptions) + ?? throw new JsonException("policy document was null"); + } + + public static PolicyDocument ParseYaml(string yaml) + { + var stream = new YamlStream(); + stream.Load(new StringReader(yaml)); + var json = YamlToJson(stream.Documents[0].RootNode)?.ToJsonString() + ?? throw new JsonException("policy YAML document was empty"); + return ParseJson(json); + } + + public string ToJson() => JsonSerializer.Serialize(this, PolicyJson.Options); + + private static JsonNode? YamlToJson(YamlNode node) + { + switch (node) + { + case YamlMappingNode map: + var obj = new JsonObject(); + foreach (var (key, value) in map.Children) + { + obj[((YamlScalarNode)key).Value!] = YamlToJson(value); + } + + return obj; + + case YamlSequenceNode seq: + var arr = new JsonArray(); + foreach (var item in seq.Children) + { + arr.Add(YamlToJson(item)); + } + + return arr; + + case YamlScalarNode scalar: + return ScalarToJson(scalar); + + default: + return null; + } + } + + private static JsonNode? ScalarToJson(YamlScalarNode scalar) + { + var value = scalar.Value; + if (value is null) + { + return null; + } + + if (scalar.Style is ScalarStyle.SingleQuoted or ScalarStyle.DoubleQuoted) + { + return JsonValue.Create(value); + } + + return value switch + { + "" or "null" or "~" => null, + "true" or "True" => JsonValue.Create(true), + "false" or "False" => JsonValue.Create(false), + _ when long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l) => JsonValue.Create(l), + _ when double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d) => JsonValue.Create(d), + _ => JsonValue.Create(value), + }; + } +} + +public sealed class PolicyMetadata +{ + [JsonPropertyName("Id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("Publisher")] + public string Publisher { get; set; } = ""; + + [JsonPropertyName("Revision")] + public int Revision { get; set; } + + [JsonPropertyName("PublishedAt")] + public DateTimeOffset PublishedAt { get; set; } + + [JsonPropertyName("ValidFrom")] + public DateTimeOffset? ValidFrom { get; set; } + + [JsonPropertyName("ValidUntil")] + public DateTimeOffset? ValidUntil { get; set; } + + [JsonPropertyName("Description")] + public string? Description { get; set; } + + [JsonPropertyName("SupportUrl")] + public string? SupportUrl { get; set; } +} + +public sealed class PolicyEnforcement +{ + [JsonPropertyName("DefaultDecision")] + public Decision DefaultDecision { get; set; } + + [JsonPropertyName("RulePrecedence")] + public RulePrecedence RulePrecedence { get; set; } + + [JsonPropertyName("AuditMode")] + public bool? AuditMode { get; set; } +} + +public sealed class PolicyRule +{ + [JsonPropertyName("Id")] + public string Id { get; set; } = ""; + + [JsonPropertyName("Enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("Priority")] + public int Priority { get; set; } + + [JsonPropertyName("Decision")] + public Decision Decision { get; set; } + + [JsonPropertyName("Reason")] + public string? Reason { get; set; } + + [JsonPropertyName("Match")] + public PolicyMatch Match { get; set; } = new(); + + [JsonPropertyName("Constraints")] + public PolicyConstraints? Constraints { get; set; } +} + +public sealed class PolicyMatch +{ + [JsonPropertyName("Operations")] + public List Operations { get; set; } = []; + + [JsonPropertyName("Managers")] + public List Managers { get; set; } = []; + + [JsonPropertyName("Sources")] + public List Sources { get; set; } = []; + + [JsonPropertyName("PackageIdentifiers")] + public List PackageIdentifiers { get; set; } = []; + + [JsonPropertyName("PackageNames")] + public List PackageNames { get; set; } = []; + + [JsonPropertyName("Versions")] + public List Versions { get; set; } = []; + + [JsonPropertyName("VersionRange")] + public VersionRange? VersionRange { get; set; } + + [JsonPropertyName("Scopes")] + public List Scopes { get; set; } = []; + + [JsonPropertyName("Architectures")] + public List Architectures { get; set; } = []; + + [JsonPropertyName("Elevation")] + public List Elevation { get; set; } = []; + + [JsonPropertyName("Interactive")] + public List Interactive { get; set; } = []; + + [JsonPropertyName("SkipHashCheck")] + public List SkipHashCheck { get; set; } = []; + + [JsonPropertyName("PreRelease")] + public List PreRelease { get; set; } = []; + + [JsonPropertyName("HasCustomParameters")] + public List HasCustomParameters { get; set; } = []; + + [JsonPropertyName("HasCustomInstallLocation")] + public List HasCustomInstallLocation { get; set; } = []; + + [JsonPropertyName("HasPrePostCommands")] + public List HasPrePostCommands { get; set; } = []; + + [JsonPropertyName("HasKillBeforeOperation")] + public List HasKillBeforeOperation { get; set; } = []; + + [JsonPropertyName("HasUninstallPrevious")] + public List HasUninstallPrevious { get; set; } = []; +} + +public sealed class VersionRange +{ + [JsonPropertyName("MinVersion")] + public string? MinVersion { get; set; } + + [JsonPropertyName("MaxVersion")] + public string? MaxVersion { get; set; } + + [JsonPropertyName("IncludePrerelease")] + public bool IncludePrerelease { get; set; } +} + +public sealed class PolicyConstraints +{ + [JsonPropertyName("AllowInteractive")] + public bool AllowInteractive { get; set; } = true; + + [JsonPropertyName("AllowSkipHashCheck")] + public bool AllowSkipHashCheck { get; set; } = true; + + [JsonPropertyName("AllowPreRelease")] + public bool AllowPreRelease { get; set; } = true; + + [JsonPropertyName("AllowCustomInstallLocation")] + public bool AllowCustomInstallLocation { get; set; } = true; + + [JsonPropertyName("AllowedInstallLocationPatterns")] + public List AllowedInstallLocationPatterns { get; set; } = []; + + [JsonPropertyName("AllowCustomParameters")] + public bool AllowCustomParameters { get; set; } = true; + + [JsonPropertyName("AllowedCustomParameters")] + public List AllowedCustomParameters { get; set; } = []; + + [JsonPropertyName("AllowedCustomParameterPatterns")] + public List AllowedCustomParameterPatterns { get; set; } = []; + + [JsonPropertyName("DeniedCustomParameters")] + public List DeniedCustomParameters { get; set; } = []; + + [JsonPropertyName("AllowPrePostCommands")] + public bool AllowPrePostCommands { get; set; } = true; + + [JsonPropertyName("AllowKillBeforeOperation")] + public bool AllowKillBeforeOperation { get; set; } = true; + + [JsonPropertyName("AllowUninstallPrevious")] + public bool AllowUninstallPrevious { get; set; } = true; + + [JsonPropertyName("AllowUpgrade")] + public bool AllowUpgrade { get; set; } = true; +} From cd5e76918c7d57d1a36b35f2f3cf5a9fc7bad9c1 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Mon, 15 Jun 2026 23:53:14 +0300 Subject: [PATCH 2/4] fix: removed schema from gitattributes --- .gitattributes | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitattributes b/.gitattributes index f55140188..07c90841b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,4 +18,3 @@ devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge= # Sample assets and schema files produce huge LoC counts; exclude them from language statistics and # treat them as generated files. unigetui/crates/uniget-broker-policy/assets/** linguist-generated -unigetui/crates/uniget-broker-policy/schema/** linguist-generated From 23aae4e5a7be0a997128e00b840a9ebcb48d7466 Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 16 Jun 2026 15:40:49 +0300 Subject: [PATCH 3/4] refacotring --- .gitattributes | 1 + .../unigetui.package-policy.schema.json | 5 +- .../uniget-broker-policy/src/markers.rs | 2 +- .../crates/uniget-broker-policy/src/policy.rs | 31 +++++++- .../tests/policy_samples.rs | 10 +++ .../PolicyTests.cs | 76 +++++++++++++++++++ .../PolicyModels.cs | 16 +++- 7 files changed, 134 insertions(+), 7 deletions(-) diff --git a/.gitattributes b/.gitattributes index 07c90841b..33c2b34f4 100644 --- a/.gitattributes +++ b/.gitattributes @@ -18,3 +18,4 @@ devolutions-gateway/openapi/ts-angular-client/model/** linguist-generated merge= # Sample assets and schema files produce huge LoC counts; exclude them from language statistics and # treat them as generated files. unigetui/crates/uniget-broker-policy/assets/** linguist-generated +unigetui/crates/uniget-broker-policy/schema/*.json linguist-generated diff --git a/unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json b/unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json index 5acdfd95e..c824093ee 100644 --- a/unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json +++ b/unigetui/crates/uniget-broker-policy/schema/unigetui.package-policy.schema.json @@ -460,7 +460,8 @@ "$ref": "#/definitions/PolicyMatch" } ], - "description": "Match criteria — request must satisfy all specified fields. At least one criterion must be present." + "description": "Match criteria — request must satisfy all specified fields. At least one criterion must be present.", + "minProperties": 1 }, "Priority": { "description": "Priority (lower = higher precedence).", @@ -594,7 +595,7 @@ "$ref": "#/definitions/PackageBrokerPolicy" } ], - "description": "Must be `\"packageBrokerPolicy\"`." + "description": "Must be `\"PackageBrokerPolicy\"`." }, "PolicyVersion": { "allOf": [ diff --git a/unigetui/crates/uniget-broker-policy/src/markers.rs b/unigetui/crates/uniget-broker-policy/src/markers.rs index 4099f7a01..49ef431a8 100644 --- a/unigetui/crates/uniget-broker-policy/src/markers.rs +++ b/unigetui/crates/uniget-broker-policy/src/markers.rs @@ -5,7 +5,7 @@ use schemars::r#gen::SchemaGenerator; use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec}; use serde::{Deserialize, Serialize}; -/// Marker type for policy type: serializes to `"packageBrokerPolicy"`. +/// Marker type for policy type: serializes to `"PackageBrokerPolicy"`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PackageBrokerPolicy; diff --git a/unigetui/crates/uniget-broker-policy/src/policy.rs b/unigetui/crates/uniget-broker-policy/src/policy.rs index ce2531b06..bf0b3e599 100644 --- a/unigetui/crates/uniget-broker-policy/src/policy.rs +++ b/unigetui/crates/uniget-broker-policy/src/policy.rs @@ -4,6 +4,8 @@ use std::collections::BTreeSet; use chrono::{DateTime, Utc}; use schemars::JsonSchema; +use schemars::r#gen::SchemaGenerator; +use schemars::schema::{ObjectValidation, Schema, SchemaObject, SubschemaValidation}; use serde::{Deserialize, Serialize}; use crate::{ @@ -24,7 +26,7 @@ pub struct PolicyDocument { /// Policy syntax version (semver). pub policy_version: SemanticVersion, - /// Must be `"packageBrokerPolicy"`. + /// Must be `"PackageBrokerPolicy"`. pub policy_type: PackageBrokerPolicy, /// Policy metadata. @@ -128,6 +130,7 @@ pub struct PolicyRule { /// Match criteria — request must satisfy all specified fields. /// At least one criterion must be present. #[serde(rename = "Match", deserialize_with = "deserialize_non_empty_match")] + #[schemars(with = "NonEmptyPolicyMatchSchema")] pub match_criteria: PolicyMatch, /// Additional constraints applied after matching. @@ -148,6 +151,32 @@ fn deserialize_non_empty_match<'de, D: serde::Deserializer<'de>>(deserializer: D Ok(m) } +struct NonEmptyPolicyMatchSchema; + +impl JsonSchema for NonEmptyPolicyMatchSchema { + fn is_referenceable() -> bool { + false + } + + fn schema_name() -> String { + "NonEmptyPolicyMatch".to_owned() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + object: Some(Box::new(ObjectValidation { + min_properties: Some(1), + ..Default::default() + })), + subschemas: Some(Box::new(SubschemaValidation { + all_of: Some(vec![generator.subschema_for::()]), + ..Default::default() + })), + ..Default::default() + }) + } +} + /// Match criteria for a policy rule. All specified fields must match. /// At least one field must be present. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] diff --git a/unigetui/crates/uniget-broker-policy/tests/policy_samples.rs b/unigetui/crates/uniget-broker-policy/tests/policy_samples.rs index 7d0ff15b8..2f4e98856 100644 --- a/unigetui/crates/uniget-broker-policy/tests/policy_samples.rs +++ b/unigetui/crates/uniget-broker-policy/tests/policy_samples.rs @@ -82,3 +82,13 @@ fn policy_schema_generates_valid_json() { "schema should have type definitions" ); } + +#[test] +fn policy_match_schema_requires_at_least_one_property() { + let schema = unigetui_broker_policy::schema::policy_schema_json(); + let min_properties = schema + .pointer("/definitions/PolicyRule/properties/Match/minProperties") + .and_then(serde_json::Value::as_u64); + + assert_eq!(min_properties, Some(1)); +} diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs index 50047f25f..f3fdd69c6 100644 --- a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy.Tests/PolicyTests.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +using System.Text.Json; using NJsonSchema; @@ -63,6 +64,59 @@ public void Invalid_policy_fixture_is_rejected_by_parser() Assert.ThrowsAny(() => PolicyDocument.ParseJson(content)); } + [Theory] + [InlineData("")] + [InlineData(" ")] + public void Empty_yaml_is_rejected_with_json_exception(string yaml) + { + Assert.Throws(() => PolicyDocument.ParseYaml(yaml)); + } + + [Fact] + public void Yaml_with_non_scalar_mapping_key_is_rejected_with_json_exception() + { + const string yaml = """ + ? [PolicyVersion] + : 1.0.0 + """; + + Assert.Throws(() => PolicyDocument.ParseYaml(yaml)); + } + + [Fact] + public void Negative_revision_is_rejected_by_parser() + { + var json = MinimalPolicyJson(""" + "Revision": -1, + """, """ + "Rules": [] + """); + + Assert.Throws(() => PolicyDocument.ParseJson(json)); + } + + [Fact] + public void Negative_priority_is_rejected_by_parser() + { + var json = MinimalPolicyJson(""" + "Revision": 1, + """, """ + "Rules": [ + { + "Id": "deny.test", + "Enabled": true, + "Priority": -1, + "Decision": "Deny", + "Match": { + "Operations": ["Install"] + } + } + ] + """); + + Assert.Throws(() => PolicyDocument.ParseJson(json)); + } + private static PolicyDocument ParsePolicy(string path) { var content = File.ReadAllText(path); @@ -78,4 +132,26 @@ private static string ResolvePolicyCrateRoot([CallerFilePath] string thisFile = var testsDir = Path.GetDirectoryName(thisFile)!; return Path.GetFullPath(Path.Combine(testsDir, "..", "..", "crates", "uniget-broker-policy")); } + + private static string MinimalPolicyJson(string revision, string rules) + { + return $$""" + { + "$schema": "https://aka.ms/unigetui/package-policy.schema.1.0.json", + "PolicyVersion": "1.0.0", + "PolicyType": "PackageBrokerPolicy", + "Metadata": { + "Id": "test.policy", + "Publisher": "Test", + {{revision}} + "PublishedAt": "2026-01-01T00:00:00Z" + }, + "Enforcement": { + "DefaultDecision": "Deny", + "RulePrecedence": "PriorityThenDeny" + }, + {{rules}} + } + """; + } } diff --git a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs index bf338d1cf..e750b93b9 100644 --- a/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs +++ b/unigetui/dotnet/Devolutions.UniGetUI.Broker.Policy/PolicyModels.cs @@ -63,6 +63,11 @@ public static PolicyDocument ParseYaml(string yaml) { var stream = new YamlStream(); stream.Load(new StringReader(yaml)); + if (stream.Documents.Count == 0) + { + throw new JsonException("policy YAML document was empty"); + } + var json = YamlToJson(stream.Documents[0].RootNode)?.ToJsonString() ?? throw new JsonException("policy YAML document was empty"); return ParseJson(json); @@ -78,7 +83,12 @@ public static PolicyDocument ParseYaml(string yaml) var obj = new JsonObject(); foreach (var (key, value) in map.Children) { - obj[((YamlScalarNode)key).Value!] = YamlToJson(value); + if (key is not YamlScalarNode scalarKey || scalarKey.Value is null) + { + throw new JsonException("policy YAML mapping keys must be scalar strings"); + } + + obj[scalarKey.Value] = YamlToJson(value); } return obj; @@ -134,7 +144,7 @@ public sealed class PolicyMetadata public string Publisher { get; set; } = ""; [JsonPropertyName("Revision")] - public int Revision { get; set; } + public uint Revision { get; set; } [JsonPropertyName("PublishedAt")] public DateTimeOffset PublishedAt { get; set; } @@ -173,7 +183,7 @@ public sealed class PolicyRule public bool Enabled { get; set; } = true; [JsonPropertyName("Priority")] - public int Priority { get; set; } + public uint Priority { get; set; } [JsonPropertyName("Decision")] public Decision Decision { get; set; } From efacd72f78e70eab310451ccb9af961e1e38420b Mon Sep 17 00:00:00 2001 From: Vladyslav Nikonov Date: Tue, 16 Jun 2026 16:49:27 +0300 Subject: [PATCH 4/4] remove marker types code duplication --- .../uniget-broker-policy/src/markers.rs | 114 ++++++++---------- 1 file changed, 47 insertions(+), 67 deletions(-) diff --git a/unigetui/crates/uniget-broker-policy/src/markers.rs b/unigetui/crates/uniget-broker-policy/src/markers.rs index 49ef431a8..3882b5fa8 100644 --- a/unigetui/crates/uniget-broker-policy/src/markers.rs +++ b/unigetui/crates/uniget-broker-policy/src/markers.rs @@ -5,82 +5,62 @@ use schemars::r#gen::SchemaGenerator; use schemars::schema::{InstanceType, Schema, SchemaObject, SingleOrVec}; use serde::{Deserialize, Serialize}; -/// Marker type for policy type: serializes to `"PackageBrokerPolicy"`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PackageBrokerPolicy; +macro_rules! fixed_string_marker { + ( + $(#[$attr:meta])* + $vis:vis struct $name:ident => $value:expr; + ) => { + $(#[$attr])* + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + $vis struct $name; -impl Serialize for PackageBrokerPolicy { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str("PackageBrokerPolicy") - } -} - -impl<'de> Deserialize<'de> for PackageBrokerPolicy { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - if s == "PackageBrokerPolicy" { - Ok(Self) - } else { - Err(serde::de::Error::custom(format!( - "expected \"PackageBrokerPolicy\", got \"{s}\"" - ))) + impl Serialize for $name { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str($value) + } } - } -} - -impl JsonSchema for PackageBrokerPolicy { - fn schema_name() -> String { - "PackageBrokerPolicy".to_owned() - } - fn json_schema(_gen: &mut SchemaGenerator) -> Schema { - SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - enum_values: Some(vec![serde_json::Value::String("PackageBrokerPolicy".to_owned())]), - ..Default::default() + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value == $value { + Ok(Self) + } else { + Err(serde::de::Error::custom(format_args!( + "expected {:?}, got {:?}", + $value, value + ))) + } + } } - .into() - } -} -/// Schema URI for package policy documents. -pub const POLICY_SCHEMA_URI: &str = "https://aka.ms/unigetui/package-policy.schema.1.0.json"; + impl JsonSchema for $name { + fn schema_name() -> String { + stringify!($name).to_owned() + } -/// Marker type for the policy `$schema` field. -/// Serializes to the canonical policy schema URI. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct PolicySchemaUri; - -impl Serialize for PolicySchemaUri { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(POLICY_SCHEMA_URI) - } + fn json_schema(_gen: &mut SchemaGenerator) -> Schema { + SchemaObject { + instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), + enum_values: Some(vec![serde_json::Value::String($value.to_owned())]), + ..Default::default() + } + .into() + } + } + }; } -impl<'de> Deserialize<'de> for PolicySchemaUri { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - if s == POLICY_SCHEMA_URI { - Ok(Self) - } else { - Err(serde::de::Error::custom(format!( - "expected \"{POLICY_SCHEMA_URI}\", got \"{s}\"" - ))) - } - } +fixed_string_marker! { + /// Marker type for policy type: serializes to `"PackageBrokerPolicy"`. + pub struct PackageBrokerPolicy => "PackageBrokerPolicy"; } -impl JsonSchema for PolicySchemaUri { - fn schema_name() -> String { - "PolicySchemaUri".to_owned() - } +/// Schema URI for package policy documents. +pub const POLICY_SCHEMA_URI: &str = "https://aka.ms/unigetui/package-policy.schema.1.0.json"; - fn json_schema(_gen: &mut SchemaGenerator) -> Schema { - SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - enum_values: Some(vec![serde_json::Value::String(POLICY_SCHEMA_URI.to_owned())]), - ..Default::default() - } - .into() - } +fixed_string_marker! { + /// Marker type for the policy `$schema` field. + /// Serializes to the canonical policy schema URI. + pub struct PolicySchemaUri => POLICY_SCHEMA_URI; }